2095 lines
58 KiB
HTML
2095 lines
58 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>
|
||
<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>
|
||
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() {
|
||
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,
|
||
});
|
||
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').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) {
|
||
// 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 && 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>
|