Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,10 @@ input[type="checkbox"] {
|
||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
||||
|
||||
/* Large tap targets for the launcher and off-screen primary actions. */
|
||||
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
|
||||
.launcher .btn-big:first-of-type { margin-top: 4px; }
|
||||
|
||||
/* Drift/nudge actions live in the status band; lay them out below the text. */
|
||||
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
||||
@@ -176,3 +180,13 @@ input[type="checkbox"] {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
||||
|
||||
/* Off-screen mode: pending spinner. */
|
||||
.offscreen-pending { text-align: center; }
|
||||
.spinner {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 3px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite; margin: 4px auto 10px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
|
||||
+79
-12
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
|
||||
const view = document.getElementById('view');
|
||||
let countdownTimer = null;
|
||||
let renderedState = null; // which runtime_state the DOM currently shows
|
||||
let renderedMode = null; // the active_mode the DOM currently shows
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
|
||||
function post(path, body) {
|
||||
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
|
||||
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
function renderFocus(state) {
|
||||
setStateAttr(state);
|
||||
const rs = state.runtime_state;
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
@@ -248,7 +249,7 @@ function render(state) {
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
||||
</div>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
document.getElementById('plan').onclick = () => post('/mode/focus/start');
|
||||
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
||||
@@ -276,7 +277,7 @@ function render(state) {
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||
[na, sc, mins].forEach(el => el.oninput = check);
|
||||
start.onclick = () => post('/commitment', {
|
||||
start.onclick = () => post('/mode/command/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
@@ -286,7 +287,7 @@ function render(state) {
|
||||
});
|
||||
document.getElementById('sharpen').onclick = () => {
|
||||
const intent = document.getElementById('intent').value.trim();
|
||||
if (intent) post('/coach', { intent });
|
||||
if (intent) post('/mode/command/coach', { intent });
|
||||
};
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
@@ -304,7 +305,7 @@ function render(state) {
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
||||
document.getElementById('done').onclick = () => post('/complete');
|
||||
document.getElementById('done').onclick = () => post('/mode/command/complete');
|
||||
const t = document.getElementById('t');
|
||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||
tick();
|
||||
@@ -322,16 +323,82 @@ function render(state) {
|
||||
${reflectionBlock(state.reflection)}
|
||||
${reviewSummary(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
document.getElementById('end').onclick = () => post('/end');
|
||||
document.getElementById('end').onclick = () => post('/mode/command/end');
|
||||
|
||||
} else {
|
||||
view.textContent = rs;
|
||||
}
|
||||
}
|
||||
|
||||
// renderLauncher shows the idle screen with mode-start buttons.
|
||||
function renderLauncher() {
|
||||
app.dataset.state = 'locked';
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Keel</span></div>
|
||||
<div class="band launcher">
|
||||
<p class="meta">No active mode.</p>
|
||||
<button id="startFocus" class="btn btn-primary btn-big">Start focus</button>
|
||||
<button id="startOffscreen" class="btn btn-ghost btn-big">Off-screen</button>
|
||||
</div>`;
|
||||
document.getElementById('startFocus').onclick = () => post('/mode/focus/start');
|
||||
document.getElementById('startOffscreen').onclick = () => post('/mode/offscreen/start');
|
||||
}
|
||||
|
||||
// renderOffscreen shows the off-screen mode card. Basic but functional; Task 22
|
||||
// polishes the phone-first styling.
|
||||
function renderOffscreen(m) {
|
||||
app.dataset.state = 'review';
|
||||
const status = m.status || 'pending';
|
||||
let body;
|
||||
if (status === 'proposed') {
|
||||
body = `<div class="band">
|
||||
<div class="action">${esc(m.next_action || '')}</div>
|
||||
<p class="meta">${esc(m.rationale || '')}</p>
|
||||
</div>
|
||||
<div class="band band-actions">
|
||||
<button id="osDo" class="btn btn-primary btn-big">Do it</button>
|
||||
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
|
||||
</div>`;
|
||||
} else if (status === 'error') {
|
||||
body = `<div class="band">
|
||||
<p class="meta">${esc(m.error || 'Could not propose an action.')}</p>
|
||||
</div>
|
||||
<div class="band band-actions">
|
||||
<button id="osRetry" class="btn btn-primary btn-big">Retry</button>
|
||||
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
|
||||
</div>`;
|
||||
} else { // pending / done
|
||||
body = `<div class="band offscreen-pending">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<p class="meta">Finding a worthwhile off-screen action…</p>
|
||||
</div>`;
|
||||
}
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Off-screen</span></div>${body}`;
|
||||
const doBtn = document.getElementById('osDo');
|
||||
if (doBtn) doBtn.onclick = () => post('/mode/command/confirm');
|
||||
const disBtn = document.getElementById('osDismiss');
|
||||
if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss');
|
||||
const retryBtn = document.getElementById('osRetry');
|
||||
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
|
||||
}
|
||||
|
||||
// route dispatches the state envelope to the active mode's renderer. On a mode
|
||||
// switch it resets the focus in-place trackers so stale DOM never leaks across.
|
||||
function route(env) {
|
||||
const am = (env && env.active_mode) || '';
|
||||
if (am !== renderedMode) {
|
||||
renderedMode = am;
|
||||
renderedState = null;
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
}
|
||||
if (am === '') return renderLauncher();
|
||||
if (am === 'focus') return renderFocus(env.mode || {});
|
||||
if (am === 'offscreen') return renderOffscreen(env.mode || {});
|
||||
view.textContent = am; // unknown mode: degrade visibly
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
es.onmessage = (e) => route(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
|
||||
|
||||
// ---- Settings overlay ----
|
||||
function openSettings() {
|
||||
@@ -349,7 +416,7 @@ function openSettings() {
|
||||
<input id="setMarvin" placeholder="uv run am">
|
||||
<label>Knowledge profile path</label>
|
||||
<div class="path-row">
|
||||
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
||||
<input id="setKnow" placeholder="~/.keel/knowledge.md">
|
||||
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||
</div>
|
||||
<div id="browsePane" class="browse" hidden></div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<title>Keel</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user