The server's serializeAsHTML() returns the full terminal screen state, not incremental chunks. Updated the dashboard to: 1. Handle initial_state event to receive current terminal state on connection 2. Replace output instead of appending (output event now replaces session.output) 3. Simplify renderSessionOutput() to always do full innerHTML replacement This fixes the issue where output was being duplicated/appended incorrectly.
2146 lines
64 KiB
HTML
2146 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>clarc</title>
|
||
<link rel="icon" id="favicon" type="image/svg+xml">
|
||
<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;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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 onclick="location.reload()">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 type="module">
|
||
// Dynamic favicon state system
|
||
const FAVICON_COLORS = { idle: "#4abe4a", processing: "#e6a835", error: "#e63946" };
|
||
const FAVICON_PATH = "M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z";
|
||
|
||
function setFaviconState(state) {
|
||
const link = document.querySelector('link[rel="icon"]');
|
||
if (!link) return;
|
||
const color = FAVICON_COLORS[state] || FAVICON_COLORS.idle;
|
||
const svg = `<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg"><path fill="${color}" stroke="none" d="${FAVICON_PATH}"/></svg>`;
|
||
link.href = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||
}
|
||
|
||
function updateFaviconFromState() {
|
||
// Check for pending prompts first
|
||
const hasPending = Array.from(state.prompts.values()).some(p => p);
|
||
if (hasPending) {
|
||
setFaviconState('processing');
|
||
return;
|
||
}
|
||
// Check session states
|
||
const states = Array.from(state.sessions.values()).map(s => s.state);
|
||
if (states.includes('thinking') || states.includes('permission') || states.includes('question')) {
|
||
setFaviconState('processing');
|
||
} else if (states.includes('interrupted')) {
|
||
setFaviconState('error');
|
||
} else {
|
||
setFaviconState('idle');
|
||
}
|
||
}
|
||
|
||
// Set initial favicon state
|
||
setFaviconState('idle');
|
||
|
||
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,
|
||
});
|
||
});
|
||
renderSessions();
|
||
} catch (error) {
|
||
console.error('Error fetching existing sessions:', error);
|
||
}
|
||
}
|
||
|
||
function connectSSE() {
|
||
// Clean up any prior connection attempt
|
||
if (state.eventSource) {
|
||
state.eventSource.close();
|
||
}
|
||
if (state.reconnectTimeout) {
|
||
clearTimeout(state.reconnectTimeout);
|
||
state.reconnectTimeout = null;
|
||
}
|
||
|
||
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();
|
||
state.eventSource = null;
|
||
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,
|
||
});
|
||
renderSessions();
|
||
});
|
||
|
||
es.addEventListener('session_end', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
state.sessions.delete(data.session_id);
|
||
renderSessions();
|
||
});
|
||
|
||
es.addEventListener('initial_state', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = state.sessions.get(data.session_id);
|
||
if (session) {
|
||
// Replace output with current terminal state
|
||
session.output = data.html;
|
||
session.outputRenderedLength = 0; // Force re-render
|
||
renderSessionOutput(data.session_id);
|
||
}
|
||
});
|
||
|
||
es.addEventListener('output', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = state.sessions.get(data.session_id);
|
||
if (session) {
|
||
session.output = data.data; // Replace, not append
|
||
session.outputRenderedLength = 0; // Force full re-render
|
||
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();
|
||
updateFaviconFromState();
|
||
});
|
||
|
||
es.addEventListener('prompt_response', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
state.prompts.delete(data.prompt_id);
|
||
renderPrompts();
|
||
updateFaviconFromState();
|
||
});
|
||
|
||
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);
|
||
updateFaviconFromState();
|
||
}
|
||
});
|
||
|
||
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').replace('--dangerously-skip-permissions', '--yolo'))}</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"
|
||
onclick="scrollToBottom(${s.id}); event.stopPropagation();"
|
||
title="Scroll to bottom">
|
||
↓
|
||
</button>
|
||
<div class="terminal" id="output-${s.id}">${s.output}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderSessionOutput(sessionId) {
|
||
const session = state.sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
const $output = document.getElementById(`output-${sessionId}`);
|
||
if ($output) {
|
||
// Always full replace since serializeAsHTML() returns full screen state
|
||
$output.innerHTML = session.output;
|
||
session.outputRenderedLength = session.output.length;
|
||
|
||
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
|
||
if ($outputContainer && session.expanded) {
|
||
$outputContainer.scrollTop = $outputContainer.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
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').replace('--dangerously-skip-permissions', '--yolo'))}</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').replace('--dangerously-skip-permissions', '--yolo'))}</span>
|
||
${renderStateBadge(s.state || 'ready')}
|
||
</div>
|
||
<div class="session-filter-cwd">${escapeHtml(s.cwd || '~')}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
window.scrollToBottom = (sessionId) => {
|
||
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
|
||
if ($outputContainer) {
|
||
$outputContainer.scrollTop = $outputContainer.scrollHeight;
|
||
}
|
||
};
|
||
|
||
// Initialize
|
||
loadSettings();
|
||
applySettings();
|
||
connectSSE();
|
||
|
||
// Set up window resize listener for PTY viewport resize
|
||
window.addEventListener('resize', handleWindowResize);
|
||
</script>
|
||
</body>
|
||
</html>
|