mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: render the approval card, composer, search and digest
The preview pane now leads with the pending dialog when the selected session has one: the question, the options with their digits, and the keys that answer them, red for a dialog and yellow for a waiting prompt. The card is capped at half the pane, because the tail is why the pane exists. Around it: a header badge counting prompts that need a human, a preview title that sacrifices the path rather than the state word, the footer becoming the composer line while one is open (with the cell the terminal cursor belongs in, so it can be shown there and hidden everywhere else), and the search and digest panels as overlays with a stable width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+210
-11
@@ -20,13 +20,20 @@
|
||||
*/
|
||||
|
||||
import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js';
|
||||
import { approvalCard } from './tui-approvals.js';
|
||||
import { composerText, composerWindow } from './tui-composer.js';
|
||||
import type { TuiLayout, TuiRect } from './tui-layout.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type { StatusTelemetry } from '../usage-telemetry.js';
|
||||
import type {
|
||||
TuiDigestState,
|
||||
TuiGlyphTier,
|
||||
TuiGroup,
|
||||
TuiPickerState,
|
||||
TuiPromptState,
|
||||
TuiRenderModel,
|
||||
TuiRow,
|
||||
TuiSearchState,
|
||||
TuiSessionRow,
|
||||
TuiSessionState,
|
||||
} from './tui-types.js';
|
||||
@@ -71,6 +78,19 @@ const SGR = {
|
||||
gray: '\x1b[90m',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One word per state, shared by the preview title and the `--list` output so
|
||||
* both surfaces call a session the same thing.
|
||||
*/
|
||||
export const STATE_WORDS: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': 'blocked',
|
||||
'blocked-question': 'blocked',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
recent: 'done',
|
||||
};
|
||||
|
||||
const STATE_COLOR: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': SGR.red,
|
||||
'blocked-question': SGR.red,
|
||||
@@ -209,6 +229,24 @@ export function formatTokens(total: number): string {
|
||||
return `${trimTrailingZero((total / 1_000_000).toFixed(1))}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's plan-usage chip: `5h 32% · wk 61%`, the same two windows the web
|
||||
* chip shows (the statusline telemetry carries no others). Empty when the
|
||||
* account reports neither, so the header shows no placeholder for a fact that
|
||||
* does not exist.
|
||||
*/
|
||||
export function formatPlanUsage(usage: StatusTelemetry | null | undefined): string {
|
||||
if (!usage) return '';
|
||||
const parts: string[] = [];
|
||||
if (typeof usage.fiveHour?.usedPercentage === 'number') {
|
||||
parts.push(`5h ${Math.round(usage.fiveHour.usedPercentage)}%`);
|
||||
}
|
||||
if (typeof usage.sevenDay?.usedPercentage === 'number') {
|
||||
parts.push(`wk ${Math.round(usage.sevenDay.usedPercentage)}%`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row is called. Same rule as the web history rows, including the
|
||||
* "(no content)" placeholder the transcript reader emits, which is not a title.
|
||||
@@ -382,6 +420,54 @@ export function computeListWindow(
|
||||
// Preview
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pending dialog, drawn above the tail: the question, the parsed options
|
||||
* with their digits, and the keys that answer them. Red for a permission or
|
||||
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
||||
* inbox uses.
|
||||
*/
|
||||
export function renderApprovalCard(
|
||||
item: ApprovalItem,
|
||||
width: number,
|
||||
glyphs: TuiGlyphSet,
|
||||
opts: TuiRenderOptions
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const card = approvalCard(item);
|
||||
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
|
||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
|
||||
const lines: string[] = [];
|
||||
const push = (text: string, style: string): void => {
|
||||
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
||||
};
|
||||
|
||||
push(` ${glyph} ${card.title}`, color);
|
||||
for (const detail of card.detail) push(` ${detail}`, SGR.gray);
|
||||
for (const option of card.options) push(` ${option.n}. ${option.label}`, '');
|
||||
push(` ${card.hint}`, SGR.gray);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The card may take half the pane at most: the tail is why the pane exists. */
|
||||
function cardCapacity(height: number): number {
|
||||
return Math.max(0, Math.floor((height - 1) / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* `name · mode · dir · state`, with the DIRECTORY absorbing the squeeze: the
|
||||
* state word is the one fact the pane exists to confirm, so it must survive a
|
||||
* narrow preview that a full path would push off the end.
|
||||
*/
|
||||
function previewTitle(row: TuiRow, width: number, glyphs: TuiGlyphSet): string {
|
||||
const { session } = row;
|
||||
const sep = ` ${glyphs.separator} `;
|
||||
const head = ` ${rowLabel(session)}${sep}${session.mode ?? 'claude'}`;
|
||||
const tail = `${sep}${STATE_WORDS[row.state]}`;
|
||||
const dirBudget = width - visibleWidth(head) - visibleWidth(tail) - visibleWidth(sep);
|
||||
const dir = session.workingDir ? truncatePathLeft(session.workingDir, Math.max(0, dirBudget), glyphs.ellipsis) : '';
|
||||
return clipStyledLine(dir ? `${head}${sep}${dir}${tail}` : `${head}${tail}`, width);
|
||||
}
|
||||
|
||||
function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRenderOptions): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
@@ -396,22 +482,32 @@ function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRender
|
||||
if (!selected) {
|
||||
lines.push(padDisplay(paint(' no session selected', SGR.gray), rect.width));
|
||||
} else {
|
||||
const { session } = selected;
|
||||
const parts = [session.mode ?? 'claude', session.workingDir ?? ''].filter((part) => part !== '');
|
||||
const title = ` ${rowLabel(session)} ${glyphs.separator} ${parts.join(` ${glyphs.separator} `)}`;
|
||||
lines.push(padDisplay(paint(clipStyledLine(title, rect.width), SGR.bold), rect.width));
|
||||
lines.push(padDisplay(paint(previewTitle(selected, rect.width, glyphs), SGR.bold), rect.width));
|
||||
}
|
||||
|
||||
const body = previewBody(model, selected, rect, opts);
|
||||
const budget = cardCapacity(rect.height);
|
||||
if (selected?.approval && budget > 0) {
|
||||
for (const line of renderApprovalCard(selected.approval, rect.width, glyphs, opts).slice(0, budget)) {
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
}
|
||||
|
||||
const body = previewBody(model, selected, rect, opts, rect.height - lines.length);
|
||||
for (const line of body) lines.push(line);
|
||||
while (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
return lines.slice(0, Math.max(0, rect.height));
|
||||
}
|
||||
|
||||
function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRect, opts: TuiRenderOptions): string[] {
|
||||
function previewBody(
|
||||
model: TuiRenderModel,
|
||||
selected: TuiRow | null,
|
||||
rect: TuiRect,
|
||||
opts: TuiRenderOptions,
|
||||
capacity: number
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const capacity = Math.max(0, rect.height - 1);
|
||||
if (capacity === 0) return [];
|
||||
if (capacity <= 0) return [];
|
||||
const hint = (text: string): string[] => [padDisplay(paint(` ${text}`, SGR.gray), rect.width)];
|
||||
|
||||
if (!selected) return [];
|
||||
@@ -420,6 +516,7 @@ function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRe
|
||||
}
|
||||
const preview = model.preview;
|
||||
if (!preview || preview.sessionId !== selected.session.sessionId) return hint('loading preview…');
|
||||
if (preview.note) return hint(preview.note);
|
||||
if (preview.error) return hint(preview.error);
|
||||
|
||||
const trimmed = [...preview.lines];
|
||||
@@ -436,6 +533,13 @@ function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRe
|
||||
// Chrome
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
|
||||
export function pendingApprovalCount(model: TuiRenderModel): number {
|
||||
let count = 0;
|
||||
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
@@ -447,7 +551,9 @@ function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRen
|
||||
planUsage ?? '',
|
||||
].filter((part) => part !== '');
|
||||
|
||||
const left = ` ${paint('codeman', SGR.bold)} ${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`;
|
||||
const pending = pendingApprovalCount(model);
|
||||
const badge = pending > 0 ? `${paint(`${glyphs.blockedPermission} ${pending}`, SGR.red)} ` : '';
|
||||
const left = ` ${paint('codeman', SGR.bold)} ${badge}${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`;
|
||||
const right = paint('? help q quit ', SGR.gray);
|
||||
const gap = layout.cols - visibleWidth(left) - visibleWidth(right);
|
||||
if (gap < 1) return padDisplay(left, layout.cols);
|
||||
@@ -485,13 +591,40 @@ const FOOTER_KEYS: Record<string, (glyphs: TuiGlyphSet) => string> = {
|
||||
'confirm-kill': (g) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`,
|
||||
message: () => 'esc dismiss',
|
||||
prompt: (g) => `${g.enter} send ${g.separator} esc cancel`,
|
||||
search: (g) => `${g.enter} open ${g.separator} esc cancel`,
|
||||
search: (g) => `${g.updown} results ${g.separator} ${g.enter} open ${g.separator} esc close`,
|
||||
digest: (g) => `j/k ${g.separator} ${g.updown} scroll ${g.separator} esc close`,
|
||||
'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`,
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's prefix. Fixed width on purpose: the terminal cursor is placed
|
||||
* by column arithmetic (`composerCursorCell`), and a prefix that changed with
|
||||
* the session name would move the cursor with it.
|
||||
*/
|
||||
export const COMPOSER_PREFIX = ' > ';
|
||||
|
||||
function renderComposerLine(prompt: TuiPromptState, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const window = composerWindow(prompt.composer, Math.max(1, layout.cols - visibleWidth(COMPOSER_PREFIX)));
|
||||
return padDisplay(`${paint(COMPOSER_PREFIX, SGR.cyan)}${window.text}`, layout.cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the terminal's own cursor belongs, or null when nothing is being typed
|
||||
* into a single-line editor. The app shows the cursor there and hides it
|
||||
* otherwise, because a blinking cursor parked in a dashboard reads as a bug.
|
||||
*/
|
||||
export function composerCursorCell(model: TuiRenderModel, layout: TuiLayout): { row: number; col: number } | null {
|
||||
if (model.mode !== 'prompt' || !model.prompt || layout.footer.height <= 0) return null;
|
||||
const prefix = visibleWidth(COMPOSER_PREFIX);
|
||||
const window = composerWindow(model.prompt.composer, Math.max(1, layout.cols - prefix));
|
||||
return { row: layout.footer.row, col: Math.min(layout.cols, prefix + 1 + window.cursorColumn) };
|
||||
}
|
||||
|
||||
function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
if (model.mode === 'prompt' && model.prompt) return renderComposerLine(model.prompt, layout, opts);
|
||||
const text = opts.footerKeys
|
||||
? opts.footerKeys.join(` ${glyphs.separator} `)
|
||||
: (FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list)(glyphs);
|
||||
@@ -505,6 +638,12 @@ function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRen
|
||||
interface OverlayContent {
|
||||
title: string;
|
||||
lines: string[];
|
||||
/**
|
||||
* Floor for the box's inner width. The search and digest panels are lists
|
||||
* people scan, so they keep a stable width instead of snapping around their
|
||||
* longest current line.
|
||||
*/
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
@@ -565,6 +704,49 @@ function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: numb
|
||||
return [...head, ...rows, ...tail];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/` overlay: the query with a caret, one status line, then the results.
|
||||
*
|
||||
* The caret is a trailing `_` rather than the terminal's own cursor, and that is
|
||||
* why the search keymap leaves left/right to the result list: a caret that
|
||||
* cannot move is honest, an invisible one that can is not.
|
||||
*/
|
||||
function searchLines(state: TuiSearchState, glyphs: TuiGlyphSet, capacity: number): string[] {
|
||||
const head = [`${composerText(state.composer)}_`];
|
||||
if (state.note) head.push(state.note);
|
||||
head.push('');
|
||||
|
||||
const budget = Math.max(1, capacity - head.length);
|
||||
if (state.entries.length === 0) {
|
||||
return [...head, state.status === 'searching' ? 'searching…' : '(type to search sessions, events and files)'];
|
||||
}
|
||||
const first = Math.max(0, Math.min(state.index - Math.floor(budget / 2), state.entries.length - budget));
|
||||
const rows = state.entries.slice(first, first + budget).map((entry, i) => {
|
||||
if (entry.kind === 'header') return entry.text;
|
||||
const marker = first + i === state.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor));
|
||||
return `${marker} ${entry.text}${entry.detail ? ` ${entry.detail}` : ''}`;
|
||||
});
|
||||
return [...head, ...rows];
|
||||
}
|
||||
|
||||
/** Lines an overlay box can show inside its border, given the body's height. */
|
||||
function overlayCapacity(height: number): number {
|
||||
return Math.max(1, height - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many digest lines fit. Exported because the app scrolls by pages and must
|
||||
* not scroll the last page into empty space, which needs this exact number.
|
||||
*/
|
||||
export function digestCapacity(layout: TuiLayout): number {
|
||||
return overlayCapacity(layout.body.height);
|
||||
}
|
||||
|
||||
function digestLines(state: TuiDigestState, capacity: number): string[] {
|
||||
const offset = Math.min(Math.max(0, state.offset), Math.max(0, state.lines.length - 1));
|
||||
return state.lines.slice(offset, offset + capacity);
|
||||
}
|
||||
|
||||
function overlayContent(
|
||||
model: TuiRenderModel,
|
||||
opts: TuiRenderOptions,
|
||||
@@ -572,9 +754,26 @@ function overlayContent(
|
||||
height: number
|
||||
): OverlayContent | null {
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const panelWidth = Math.max(20, Math.min(width - 8, 72));
|
||||
switch (model.mode) {
|
||||
case 'help':
|
||||
return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) };
|
||||
case 'search': {
|
||||
if (!model.search) return null;
|
||||
return {
|
||||
title: 'Search',
|
||||
lines: searchLines(model.search, glyphs, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'digest': {
|
||||
if (!model.digest) return null;
|
||||
return {
|
||||
title: model.digest.title,
|
||||
lines: digestLines(model.digest, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'new-session': {
|
||||
if (!model.picker) return null;
|
||||
return { title: model.picker.title, lines: pickerLines(model.picker, glyphs, Math.max(1, height - 2)) };
|
||||
@@ -611,7 +810,7 @@ function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout,
|
||||
const visible = content.lines.slice(0, Math.max(1, body.height - 2));
|
||||
const inner = Math.min(
|
||||
maxInner,
|
||||
Math.max(visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line)))
|
||||
Math.max(content.minWidth ?? 0, visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line)))
|
||||
);
|
||||
const boxWidth = inner + 4;
|
||||
const boxHeight = visible.length + 2;
|
||||
|
||||
+181
-12
@@ -8,11 +8,15 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js';
|
||||
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
|
||||
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
|
||||
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
|
||||
import {
|
||||
composerCursorCell,
|
||||
detectGlyphTier,
|
||||
digestCapacity,
|
||||
formatElapsed,
|
||||
formatPlanUsage,
|
||||
formatTokens,
|
||||
renderFrame,
|
||||
rowLabel,
|
||||
@@ -92,6 +96,12 @@ function fixture(): TuiModelStore {
|
||||
kind: 'permission',
|
||||
createdAt: NOW - 120_000,
|
||||
toolName: 'Bash',
|
||||
toolSummary: 'Bash(git push origin main)',
|
||||
options: [
|
||||
{ n: 1, label: 'Yes' },
|
||||
{ n: 2, label: "Yes, don't ask again" },
|
||||
{ n: 3, label: 'No, tell Claude what to do' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
model.select('aaa1');
|
||||
@@ -118,17 +128,17 @@ function frameLines(frame: string): string[] {
|
||||
describe('renderFrame structure', () => {
|
||||
it('paints the wide layout at 100x30', () => {
|
||||
expect(frameLines(render(fixture(), 100, 30))).toEqual([
|
||||
' codeman tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit',
|
||||
' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api',
|
||||
' 1 w6-docs ✋ 11m│ Actualizing... (2m 14s)',
|
||||
'▶ 2 w4-api-refactor ⚠ 2m 12.3k│ running tests',
|
||||
' WORKING ───────────────────────────│ warning here',
|
||||
' 3 w1-codeman ✻ 17m 45.2k│',
|
||||
' IDLE ──────────────────────────────│',
|
||||
' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit',
|
||||
' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked',
|
||||
' 1 w6-docs ✋ 11m│ ⚠ requests: Bash(git push origin main)',
|
||||
'▶ 2 w4-api-refactor ⚠ 2m 12.3k│ 1. Yes',
|
||||
" WORKING ───────────────────────────│ 2. Yes, don't ask again",
|
||||
' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do',
|
||||
' IDLE ──────────────────────────────│ y approve · n deny · digit chooses',
|
||||
' 4 w2-gallery codex ○ 2h│',
|
||||
' RECENT ────────────────────────────│',
|
||||
' 5 fix the release script ✔ 3d│',
|
||||
' │',
|
||||
' RECENT ────────────────────────────│ Actualizing... (2m 14s)',
|
||||
' 5 fix the release script ✔ 3d│ running tests',
|
||||
' │ warning here',
|
||||
' │',
|
||||
' │',
|
||||
' │',
|
||||
@@ -153,7 +163,7 @@ describe('renderFrame structure', () => {
|
||||
|
||||
it('paints the narrow two-line layout at 44x20', () => {
|
||||
expect(frameLines(render(fixture(), 44, 20))).toEqual([
|
||||
' codeman tnode · v1.19.0 · 4 sessions · 5h',
|
||||
' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions',
|
||||
' NEEDS YOU ─────────────────────────────────',
|
||||
' 1 w6-docs ✋ 11m',
|
||||
' /home/dev/docs',
|
||||
@@ -225,7 +235,7 @@ describe('color', () => {
|
||||
const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? '';
|
||||
expect(highlighted).toContain('w4-api-refactor');
|
||||
expect(highlighted).toContain('⚠');
|
||||
expect(frame).not.toContain('\x1b[31m⚠');
|
||||
expect(highlighted).not.toContain('\x1b[');
|
||||
});
|
||||
|
||||
it('emits nothing but cursor addressing when color is off', () => {
|
||||
@@ -401,3 +411,162 @@ describe('formatting helpers', () => {
|
||||
expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the approval card', () => {
|
||||
it('draws the dialog above the tail, with its digits', () => {
|
||||
const text = frameLines(render(fixture(), 100, 30)).join('\n');
|
||||
expect(text).toContain('⚠ requests: Bash(git push origin main)');
|
||||
expect(text).toContain('1. Yes');
|
||||
expect(text).toContain('3. No, tell Claude what to do');
|
||||
expect(text).toContain('y approve · n deny · digit chooses');
|
||||
// The tail is still there, below the card.
|
||||
expect(text).toContain('Actualizing...');
|
||||
});
|
||||
|
||||
it('paints a dialog red and a waiting prompt yellow', () => {
|
||||
const model = fixture();
|
||||
const frame = render(model, 100, 30, { color: true });
|
||||
expect(frame).toContain('\x1b[31m ⚠ requests');
|
||||
|
||||
model.select('bbb2');
|
||||
const idle = render(model, 100, 30, { color: true });
|
||||
expect(idle).toContain('\x1b[33m ✋');
|
||||
expect(idle).toContain('p to reply');
|
||||
});
|
||||
|
||||
it('never lets the card push the tail off the pane', () => {
|
||||
const model = fixture();
|
||||
const lines = frameLines(render(model, 100, 10));
|
||||
// 8 body lines: the card gets at most half, so some tail survives.
|
||||
expect(lines.join('\n')).toContain('warning here');
|
||||
});
|
||||
|
||||
it('counts pending prompts in the header badge', () => {
|
||||
expect(frameLines(render(fixture(), 100, 30))[0]).toContain('⚠ 2');
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]);
|
||||
expect(frameLines(render(model, 100, 30))[0]).not.toContain('⚠');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preview title', () => {
|
||||
it('names the session, its CLI, its directory and its state', () => {
|
||||
expect(frameLines(render(fixture(), 100, 30))[1]).toContain('w4-api-refactor · claude · /home/dev/api · blocked');
|
||||
});
|
||||
|
||||
it('sacrifices the path rather than the state word when the pane is narrow', () => {
|
||||
const model = fixture();
|
||||
model.setApprovals([]);
|
||||
model.select('ccc3');
|
||||
const title = frameLines(render(model, 80, 30))[1];
|
||||
expect(title).toContain('w1-codeman');
|
||||
expect(title).toContain('working');
|
||||
});
|
||||
|
||||
it('says a history row has nothing to show rather than claiming to load it', () => {
|
||||
const model = fixture();
|
||||
model.select('eee5');
|
||||
model.setPreview({ sessionId: 'eee5', lines: [], note: 'this session is not running: no live output to show' });
|
||||
expect(frameLines(render(model, 100, 30)).join('\n')).toContain('no live output to show');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the prompt composer', () => {
|
||||
function composing(text: string, cursorAt?: number): TuiModelStore {
|
||||
const model = fixture();
|
||||
let composer = createComposer(text);
|
||||
if (cursorAt !== undefined) composer = composerMove(composer, cursorAt - text.length);
|
||||
model.setPrompt({ sessionId: 'aaa1', label: 'w4-api-refactor', composer });
|
||||
return model;
|
||||
}
|
||||
|
||||
it('replaces the footer keys with the line being typed', () => {
|
||||
const lines = frameLines(render(composing('deploy the thing'), 100, 30));
|
||||
expect(lines[lines.length - 1]).toBe(' > deploy the thing');
|
||||
});
|
||||
|
||||
it('puts the terminal cursor where the caret is', () => {
|
||||
const layout = computeLayout(100, 30);
|
||||
expect(composerCursorCell(composing('abc'), layout)).toEqual({ row: 30, col: 7 });
|
||||
expect(composerCursorCell(composing('abc', 1), layout)).toEqual({ row: 30, col: 5 });
|
||||
// No composer, no cursor: a blinking cursor in a dashboard reads as a bug.
|
||||
expect(composerCursorCell(fixture(), layout)).toBeNull();
|
||||
});
|
||||
|
||||
it('scrolls a long line so the caret stays on screen', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const lines = frameLines(render(composing(long), 100, 30));
|
||||
const footer = lines[lines.length - 1];
|
||||
expect(visibleWidth(footer)).toBeLessThanOrEqual(100);
|
||||
const cursor = composerCursorCell(composing(long), computeLayout(100, 30));
|
||||
expect(cursor?.col).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the search overlay', () => {
|
||||
function searching(): TuiModelStore {
|
||||
const model = fixture();
|
||||
model.setSearch({
|
||||
composer: createComposer('alpha'),
|
||||
query: 'alpha',
|
||||
status: 'done',
|
||||
note: '2 results',
|
||||
index: 1,
|
||||
entries: [
|
||||
{ kind: 'header', text: 'SESSIONS' },
|
||||
{ kind: 'result', text: 'w1-alpha', detail: '/tmp/alpha', sessionId: 'aaa1', live: true },
|
||||
{ kind: 'result', text: 'w9-old', detail: '/tmp/old', sessionId: 'zzz9', live: false },
|
||||
],
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
it('shows the query with a caret, the count and the rows', () => {
|
||||
const text = frameLines(render(searching(), 100, 30)).join('\n');
|
||||
expect(text).toContain('┌ Search ');
|
||||
expect(text).toContain('alpha_');
|
||||
expect(text).toContain('2 results');
|
||||
expect(text).toContain('SESSIONS');
|
||||
expect(text).toContain('▶ w1-alpha /tmp/alpha');
|
||||
expect(text).toContain('w9-old');
|
||||
});
|
||||
|
||||
it('invites a query before anything has been typed', () => {
|
||||
const model = fixture();
|
||||
model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' });
|
||||
expect(frameLines(render(model, 100, 30)).join('\n')).toContain('type to search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the digest overlay', () => {
|
||||
it('windows the lines it was given and scrolls with the offset', () => {
|
||||
const model = fixture();
|
||||
const lines = Array.from({ length: 40 }, (_, i) => `digest line ${i}`);
|
||||
model.setDigest({ title: 'Away digest', lines, offset: 0 });
|
||||
const top = frameLines(render(model, 100, 12)).join('\n');
|
||||
expect(top).toContain('┌ Away digest ');
|
||||
expect(top).toContain('digest line 0');
|
||||
expect(top).not.toContain('digest line 30');
|
||||
|
||||
model.scrollDigest(30, digestCapacity(computeLayout(100, 12)));
|
||||
const scrolled = frameLines(render(model, 100, 12)).join('\n');
|
||||
expect(scrolled).toContain('digest line 30');
|
||||
expect(scrolled).not.toContain('digest line 0\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPlanUsage', () => {
|
||||
it('mirrors the web chip, both windows and either alone', () => {
|
||||
expect(
|
||||
formatPlanUsage({ fiveHour: { usedPercentage: 32.4, resetAt: 1 }, sevenDay: { usedPercentage: 61, resetAt: 2 } })
|
||||
).toBe('5h 32% · wk 61%');
|
||||
expect(formatPlanUsage({ fiveHour: { usedPercentage: 5, resetAt: 1 } })).toBe('5h 5%');
|
||||
expect(formatPlanUsage({ sevenDay: { usedPercentage: 90, resetAt: 1 } })).toBe('wk 90%');
|
||||
});
|
||||
|
||||
it('is empty when there is nothing to report, so the header shows no placeholder', () => {
|
||||
expect(formatPlanUsage(null)).toBe('');
|
||||
expect(formatPlanUsage(undefined)).toBe('');
|
||||
expect(formatPlanUsage({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user