Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,10 @@ export const cfg = {
/** ms — attention idle expiry. How long before "looking at this pane" wears off. */
userAttention: 15_000,
},
todoBucket: {
/** Seconds for a fully-drained soft-TODO bucket to refill to full when idle. */
timeToFullSeconds: 3,
/** Number of printable keypresses to drain a full bucket to zero. */
keypressesToEmpty: 5,
},
};
3 changes: 1 addition & 2 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
key={item.id}
title={item.title}
status={sessionState.status}

todo={sessionState.todo}

/>
);
})}
Expand Down Expand Up @@ -176,7 +176,6 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
title={item.title}
isActive={activeId === item.id}
status={sessionState.status}

todo={sessionState.todo}
onClick={() => onReattach(item)}
/>
Expand Down
23 changes: 15 additions & 8 deletions lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BellIcon } from '@phosphor-icons/react';
import type { SessionStatus, TodoState } from '../lib/terminal-registry';
import { TODO_OFF, isSoftTodo, hasTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry';

export interface DoorProps {
doorId?: string;
Expand All @@ -15,7 +15,7 @@ export function Door({
title,
isActive = false,
status = 'ALARM_DISABLED',
todo = false,
todo = TODO_OFF,
onClick,
}: DoorProps) {
// Doors can only be active in command mode (navigated to via arrow keys).
Expand Down Expand Up @@ -49,13 +49,20 @@ export function Door({
<span className={['min-w-0 flex-1 truncate', isActive ? 'text-foreground' : 'text-muted'].join(' ')}>
{title}
</span>
{(todo || alarmEnabled) && (
{(hasTodo(todo) || alarmEnabled) && (
<span className="flex shrink-0 items-center gap-1.5">
{todo && (
<span className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
todo === 'soft' ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}>
{hasTodo(todo) && (
<span
className={[
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
isSoftTodo(todo) ? 'border border-dashed border-border' : 'border border-border',
].join(' ')}
style={isSoftTodo(todo) ? {
opacity: 0.3 + 0.7 * todo,
transform: `scale(${0.7 + 0.3 * todo})`,
transition: 'opacity 0.15s ease, transform 0.15s ease',
} : undefined}
>
TODO
</span>
)}
Expand Down
20 changes: 14 additions & 6 deletions lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import {
destroyTerminal,
swapTerminals,
type SessionStatus,
isSoftTodo,
isHardTodo,
hasTodo,
} from '../lib/terminal-registry';
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot';
Expand Down Expand Up @@ -279,7 +282,7 @@ function AlarmContextMenu({
const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot);
const sessionState = sessionStates.get(sessionId) ?? DEFAULT_SESSION_UI_STATE;
const alarmEnabled = sessionState.status !== 'ALARM_DISABLED';
const hasHardTodo = sessionState.todo === 'hard';
const hasHardTodo = isHardTodo(sessionState.todo);
const menuRef = useRef<HTMLDivElement>(null);
const firstActionRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -340,7 +343,7 @@ function TodoPillPrompt({
const clearButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (sessionState.todo !== 'soft') {
if (!isSoftTodo(sessionState.todo)) {
onClose();
}
}, [onClose, sessionState.todo]);
Expand Down Expand Up @@ -508,7 +511,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
const [tier, setTier] = useState<HeaderTier>('full');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [todoPrompt, setTodoPrompt] = useState<{ x: number; y: number } | null>(null);
const showTodoPill = sessionState.todo !== false && tier !== 'minimal';
const showTodoPill = hasTodo(sessionState.todo) && tier !== 'minimal';
const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING'
? 'Alarm ringing'
: sessionState.status === 'ALARM_DISABLED'
Expand Down Expand Up @@ -628,13 +631,18 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
data-session-todo-for={api.id}
className={[
'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10',
sessionState.todo === 'soft' ? 'border border-dashed border-muted' : 'border border-muted',
isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted',
].join(' ')}
aria-label={sessionState.todo === 'soft' ? 'Soft TODO options' : 'Clear TODO'}
style={isSoftTodo(sessionState.todo) ? {
opacity: 0.3 + 0.7 * sessionState.todo,
transform: `scale(${0.7 + 0.3 * sessionState.todo})`,
transition: 'opacity 0.15s ease, transform 0.15s ease',
} : undefined}
aria-label={isSoftTodo(sessionState.todo) ? 'Soft TODO options' : 'Clear TODO'}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
if (sessionState.todo === 'soft') {
if (isSoftTodo(sessionState.todo)) {
const rect = e.currentTarget.getBoundingClientRect();
setTodoPrompt({ x: rect.left + rect.width / 2, y: rect.bottom + 6 });
return;
Expand Down
109 changes: 108 additions & 1 deletion lib/src/lib/alarm-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AlarmManager } from './alarm-manager';
import { AlarmManager, TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo } from './alarm-manager';

describe('AlarmManager in isolation', () => {
let manager: AlarmManager;
Expand Down Expand Up @@ -153,4 +153,111 @@ describe('AlarmManager in isolation', () => {
expect(states).toContain('MIGHT_NEED_ATTENTION');
expect(states).toContain('ALARM_RINGING');
});

// --- Soft-TODO bucket tests ---

function createSoftTodo(id: string): void {
manager.toggleAlarm(id);
manager.clearAttention(id);
// Drive to BUSY → silence → ALARM_RINGING
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
manager.onData(id);
vi.advanceTimersByTime(2_000);
vi.advanceTimersByTime(3_000);
expect(manager.getState(id).status).toBe('ALARM_RINGING');
// Attend creates soft TODO
manager.attend(id);
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
}

it('soft-TODO bucket starts full', () => {
const id = 'bucket-full';
createSoftTodo(id);
expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
});

it('5 rapid keypresses drain bucket to 0 and clear soft-TODO', () => {
const id = 'bucket-drain';
createSoftTodo(id);

for (let i = 0; i < 5; i++) {
manager.drainTodoBucket(id);
}

expect(manager.getState(id).todo).toBe(TODO_OFF);
});

it('4 keypresses drain but do not clear soft-TODO', () => {
const id = 'bucket-partial';
createSoftTodo(id);

for (let i = 0; i < 4; i++) {
manager.drainTodoBucket(id);
}

expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
expect(manager.getState(id).todo).toBeCloseTo(0.2);
});

it('bucket refills to full after timeToFull seconds of idle', () => {
const id = 'bucket-refill';
createSoftTodo(id);

manager.drainTodoBucket(id);
manager.drainTodoBucket(id);
manager.drainTodoBucket(id);
expect(manager.getState(id).todo).toBeCloseTo(0.4);

// Wait for full refill (3 seconds for full, but only need 0.6 * 3 = 1.8s)
vi.advanceTimersByTime(1_800);

expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
});

it('partial refill + more keypresses — correct math', () => {
const id = 'bucket-partial-refill';
createSoftTodo(id);

// Drain 3 times → level = 0.4
for (let i = 0; i < 3; i++) {
manager.drainTodoBucket(id);
}
expect(manager.getState(id).todo).toBeCloseTo(0.4);

// Wait 1.5s → refill = 1.5/3 = 0.5, so level = min(1, 0.4 + 0.5) = 0.9
vi.advanceTimersByTime(1_500);

// Drain once more → refill applied first, then drain: 0.9 - 0.2 = 0.7
manager.drainTodoBucket(id);
expect(manager.getState(id).todo).toBeCloseTo(0.7);
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
});

it('promoting a partially-drained soft-TODO resets to hard', () => {
const id = 'bucket-promote';
createSoftTodo(id);

manager.drainTodoBucket(id);
manager.drainTodoBucket(id);
expect(manager.getState(id).todo).toBeCloseTo(0.6);

manager.promoteTodo(id);
expect(manager.getState(id).todo).toBe(TODO_HARD);
});

it('hard TODO uses TODO_HARD constant', () => {
const id = 'bucket-hard';
manager.toggleTodo(id); // off → hard
expect(manager.getState(id).todo).toBe(TODO_HARD);
});

it('drainTodoBucket is a no-op for hard TODOs', () => {
const id = 'bucket-hard-noop';
manager.toggleTodo(id);
manager.drainTodoBucket(id);
expect(manager.getState(id).todo).toBe(TODO_HARD);
});
});
Loading
Loading