mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
The dashboard stops being read-only. The selected session's tail is polled once a second while the plain list has focus and the layout is wide, and an unchanged tail never reaches the model, so a quiet session costs no repaint. A row with no live buffer says so instead of polling forever. Keys: y/n and the parsed digits answer the selected session's dialog through `POST /api/approvals/:id/answer` (never a blind keystroke: that route re-captures the pane and 409s when the dialog has moved on, which the TUI reports as "no longer on screen"); `p` opens a one-line composer aimed at the selected session; `/` searches with a 250ms debounce and Enter switches to a live session result; `g` shows the away digest. A new prompt rings the bell exactly once, tracked by item id so a repaint or a refetch cannot stutter, and the plan-usage chip rides `GET /api/status` plus its telemetry event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1789 lines
67 KiB
TypeScript
1789 lines
67 KiB
TypeScript
/**
|
||
* @fileoverview `codeman tui`: the full-screen dashboard plus the two
|
||
* non-interactive fast paths (`--list`, `<n>`).
|
||
*
|
||
* This is the IO half of `src/tui/`: it owns the terminal, the timers, stdin
|
||
* and the tmux handoff. Every decision it makes that can be stated as a
|
||
* function of its inputs lives at the top of this file as an exported pure
|
||
* helper (attach planning, the kill confirmation, footer selection, the
|
||
* repaint test, degraded-row building), because none of those can be tested
|
||
* through a real terminal.
|
||
*
|
||
* TERMINAL LIFECYCLE is the part users judge. A TUI that dies leaving the
|
||
* terminal in raw mode with a hidden cursor is unusable until the user types a
|
||
* blind `reset`, so `leave()` is idempotent and runs from every exit path there
|
||
* is: normal quit, SIGINT/SIGTERM, and a `process.on('exit')` backstop that
|
||
* fires even when someone else's handler calls `process.exit()`. The fatal
|
||
* handlers are installed with `prependListener` on purpose: `src/index.ts`
|
||
* already handles `uncaughtException` by exiting, and a listener registered
|
||
* after it would never run.
|
||
*
|
||
* ATTACH is a handoff, never a proxy: the screen is fully restored and tmux
|
||
* gets the real terminal (`stdio: 'inherit'`), so mouse, paste and colors are
|
||
* tmux's own. Inside tmux on the same socket there is nothing to hand off to,
|
||
* so the TUI issues `switch-client` and EXITS: the client it would draw on is
|
||
* now showing the target session, and a dashboard nobody can see must not keep
|
||
* polling the server.
|
||
*
|
||
* REPAINT POLICY: on state change (the model's revision), on resize, and on a
|
||
* 500ms tick that runs only while a WORKING row is on screen (the glyph
|
||
* animates). An idle dashboard writes nothing at all. The preview's own 1s poll
|
||
* obeys the same rule: an unchanged tail never reaches the model, so it cannot
|
||
* bump the revision and cannot repaint.
|
||
*
|
||
* ANSWERING A DIALOG goes through `POST /api/approvals/:id/answer` and nothing
|
||
* else. That route re-captures the pane before it sends a keystroke and refuses
|
||
* with 409 when the dialog has moved on, which is the only reason it is safe to
|
||
* bind a single digit to it; a blind `send-keys` from here would type into
|
||
* whatever now has focus.
|
||
*
|
||
* NOT HERE YET (phase 3 of docs/tui-plan.md): mouse support, the `--pick` popup
|
||
* switcher, the opt-in attach status line, OSC 9 notifications, and resuming a
|
||
* RECENT row.
|
||
*
|
||
* @module tui/tui-app
|
||
*/
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import { hostname as osHostname } from 'node:os';
|
||
import chalk from 'chalk';
|
||
import { palette, table, tint, type Tone } from '../cli-style.js';
|
||
import { CODEMAN_INSTANCE, resolveTmuxSocketName } from '../config/instance.js';
|
||
import { getErrorMessage } from '../types/api.js';
|
||
import { toDisplayLines } from './tui-ansi.js';
|
||
import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js';
|
||
import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js';
|
||
import { formatAwayDigest } from './tui-digest.js';
|
||
import {
|
||
TuiClient,
|
||
type TuiApprovalAnswer,
|
||
type TuiEventStream,
|
||
type TuiQuickStartOptions,
|
||
type TuiTmuxSession,
|
||
} from './tui-client.js';
|
||
import { createKeyParser, type TuiInputEvent, type TuiKeyParser } from './tui-keys.js';
|
||
import { computeLayout, needsBanner, type TuiLayout } from './tui-layout.js';
|
||
import {
|
||
buildSearchEntries,
|
||
createTuiModel,
|
||
firstSearchIndex,
|
||
moveSearchIndex,
|
||
type TuiModelStore,
|
||
} from './tui-model.js';
|
||
import {
|
||
COMPOSER_PREFIX,
|
||
composerCursorCell,
|
||
detectGlyphTier,
|
||
digestCapacity,
|
||
formatPlanUsage,
|
||
glyphsFor,
|
||
renderFrame,
|
||
rowLabel,
|
||
STATE_WORDS,
|
||
type TuiGlyphSet,
|
||
} from './tui-render.js';
|
||
import type { TuiRenderOptions } from './tui-render.js';
|
||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||
import type {
|
||
TuiConfirmState,
|
||
TuiConnectionStatus,
|
||
TuiGlyphTier,
|
||
TuiPickerItem,
|
||
TuiPreview,
|
||
TuiRow,
|
||
TuiSessionRow,
|
||
TuiSessionState,
|
||
TuiUiMode,
|
||
} from './tui-types.js';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Timing
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** How long a lone ESC waits for the rest of a sequence before it counts as Escape. */
|
||
const ESC_FLUSH_MS = 30;
|
||
/** Animation period. Two frames a second is what the plan's WORKING glyph asks for. */
|
||
const TICK_MS = 500;
|
||
/** A burst of SSE events (one session change fans out to several) becomes one refetch. */
|
||
const RESYNC_DEBOUNCE_MS = 250;
|
||
/** Poll period once the client reports SSE is not carrying events. */
|
||
const POLL_INTERVAL_MS = 2_000;
|
||
/** Degraded mode re-probes this often, so a server that starts upgrades the TUI live. */
|
||
const REPROBE_INTERVAL_MS = 10_000;
|
||
/** Unified-list page size. RECENT is capped far lower by the model. */
|
||
const UNIFIED_LIMIT = 60;
|
||
/** How often the selected session's tail is re-read while the list has focus. */
|
||
const PREVIEW_INTERVAL_MS = 1_000;
|
||
/** Tail size. Enough for a tall pane's last screens, small enough to poll every second. */
|
||
const PREVIEW_TAIL_BYTES = 12 * 1024;
|
||
/** Lines kept from a tail. The pane shows a fraction of these; the rest is headroom. */
|
||
const PREVIEW_MAX_LINES = 200;
|
||
/** Quiet time after the last keystroke before the search query goes to the server. */
|
||
const SEARCH_DEBOUNCE_MS = 250;
|
||
const SEARCH_LIMIT = 40;
|
||
/** How long a "sent" style notice stays up before it clears itself. */
|
||
const NOTICE_MS = 1_500;
|
||
/** Approval ids remembered for the bell before the set is rebuilt from what is pending. */
|
||
const SEEN_APPROVAL_CAP = 500;
|
||
|
||
const ALT_SCREEN_ON = '\x1b[?1049h';
|
||
const ALT_SCREEN_OFF = '\x1b[?1049l';
|
||
const CURSOR_HIDE = '\x1b[?25l';
|
||
const CURSOR_SHOW = '\x1b[?25h';
|
||
/** DECSET 2026: terminals that know it show the frame atomically, the rest ignore it. */
|
||
const SYNC_BEGIN = '\x1b[?2026h';
|
||
const SYNC_END = '\x1b[?2026l';
|
||
/** One BEL when a prompt starts waiting on a human, and never for a repaint. */
|
||
const BELL = '\x07';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Attach planning (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type TuiAttachRefusal = 'no-mux-name' | 'nested-foreign-socket';
|
||
|
||
export type TuiAttachPlan =
|
||
| { kind: 'attach'; file: string; args: string[]; hint: string }
|
||
| { kind: 'switch'; file: string; args: string[] }
|
||
| { kind: 'refuse'; reason: TuiAttachRefusal; message: string };
|
||
|
||
export interface TuiAttachContext {
|
||
/** This instance's tmux socket name (`-L`). */
|
||
socket: string;
|
||
/** `$TMUX` as tmux sets it inside a pane: `<socket path>,<pid>,<session index>`. */
|
||
tmux?: string;
|
||
}
|
||
|
||
/**
|
||
* The socket NAME behind a `$TMUX` value, or null when we are not inside tmux.
|
||
* tmux itself splits the variable on commas, so the path can be taken as
|
||
* everything before the first one; `-L <name>` sockets live in one directory
|
||
* per user, which makes the basename the name we compare against.
|
||
*/
|
||
export function tmuxSocketFromEnv(tmux: string | undefined): string | null {
|
||
const raw = (tmux ?? '').trim();
|
||
if (!raw) return null;
|
||
const path = raw.split(',')[0];
|
||
const name = path.split('/').filter(Boolean).pop();
|
||
return name ?? null;
|
||
}
|
||
|
||
/**
|
||
* How to reach a session's pane from where we are standing.
|
||
*
|
||
* Nesting is the case worth spelling out: inside tmux on a FOREIGN socket an
|
||
* attach would either be refused by tmux or produce a terminal inside a
|
||
* terminal whose prefix keys collide, so the TUI explains the situation instead
|
||
* of trying.
|
||
*/
|
||
export function planAttach(muxName: string | undefined, context: TuiAttachContext): TuiAttachPlan {
|
||
const name = (muxName ?? '').trim();
|
||
if (!name) {
|
||
return {
|
||
kind: 'refuse',
|
||
reason: 'no-mux-name',
|
||
message: 'that session has no tmux pane to attach to (it is history, or it runs on a direct PTY)',
|
||
};
|
||
}
|
||
const inside = tmuxSocketFromEnv(context.tmux);
|
||
if (inside === null) {
|
||
return {
|
||
kind: 'attach',
|
||
file: 'tmux',
|
||
args: ['-L', context.socket, 'attach-session', '-t', name],
|
||
hint: 'detach with Ctrl+B D to come back',
|
||
};
|
||
}
|
||
if (inside === context.socket) {
|
||
return { kind: 'switch', file: 'tmux', args: ['-L', context.socket, 'switch-client', '-t', name] };
|
||
}
|
||
return {
|
||
kind: 'refuse',
|
||
reason: 'nested-foreign-socket',
|
||
message:
|
||
`this terminal is already inside tmux on socket "${inside}", and Codeman's sessions live on "${context.socket}". ` +
|
||
'Detach first (Ctrl+B D), then run codeman tui again.',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Is this the session the TUI itself runs in? Codeman exports
|
||
* `CODEMAN_SESSION_ID` into every managed pane, and killing that one would take
|
||
* the TUI down with it. Ids reach agents truncated, so either side may be the
|
||
* prefix; anything shorter than 8 characters is not identification.
|
||
*/
|
||
export function isSelfSession(sessionId: string, env: { CODEMAN_SESSION_ID?: string } = {}): boolean {
|
||
const self = (env.CODEMAN_SESSION_ID ?? '').trim();
|
||
if (self.length < 8 || sessionId.length < 8) return false;
|
||
return sessionId.startsWith(self) || self.startsWith(sessionId);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Kill confirmation (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type TuiConfirmStep =
|
||
| { kind: 'typing'; typed: string }
|
||
| { kind: 'confirm' }
|
||
| { kind: 'reject' }
|
||
| { kind: 'cancel' }
|
||
| { kind: 'ignore' };
|
||
|
||
/** Does the typed text authorize the kill? The shown name, or the id prefix a mux name carries. */
|
||
export function confirmAccepts(state: TuiConfirmState, typed = state.typed): boolean {
|
||
const value = typed.trim();
|
||
if (value === '') return false;
|
||
return value === state.name || value === state.sessionId.slice(0, 8);
|
||
}
|
||
|
||
/**
|
||
* One keystroke of the typed confirmation. Enter on text that does not match is
|
||
* a `reject`, never a silent no-op: a confirmation that appears to do nothing
|
||
* reads as a broken key.
|
||
*/
|
||
export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep {
|
||
switch (event.type) {
|
||
case 'char':
|
||
return { kind: 'typing', typed: state.typed + event.value };
|
||
case 'backspace':
|
||
return { kind: 'typing', typed: [...state.typed].slice(0, -1).join('') };
|
||
case 'enter':
|
||
return confirmAccepts(state) ? { kind: 'confirm' } : { kind: 'reject' };
|
||
case 'escape':
|
||
return { kind: 'cancel' };
|
||
case 'ctrl':
|
||
return event.key === 'c' ? { kind: 'cancel' } : { kind: 'ignore' };
|
||
default:
|
||
return { kind: 'ignore' };
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Footer (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Which approval keys the selected row makes live. `menu` is a dialog on
|
||
* screen (y/n/digits answer it); `idle` is a prompt with no dialog, where the
|
||
* only reply path is the composer.
|
||
*/
|
||
export type TuiApprovalKeys = 'menu' | 'idle' | null;
|
||
|
||
export interface TuiKeymapContext {
|
||
/** False in degraded mode, where the only verb that works is attach. */
|
||
server: boolean;
|
||
approval?: TuiApprovalKeys;
|
||
}
|
||
|
||
/**
|
||
* The footer keys for a mode. This is the honest inventory of what works RIGHT
|
||
* NOW, not a fixed list: `n` starts a session normally and denies a dialog when
|
||
* one is on the selected row, and a footer that advertised both at once would
|
||
* be wrong half the time.
|
||
*/
|
||
export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: TuiKeymapContext): string[] {
|
||
switch (mode) {
|
||
case 'help':
|
||
return ['esc close'];
|
||
case 'confirm-kill':
|
||
return ['type the name', `${glyphs.enter} confirm`, 'esc cancel'];
|
||
case 'message':
|
||
return ['esc dismiss'];
|
||
case 'new-session':
|
||
return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel'];
|
||
case 'prompt':
|
||
return [`${glyphs.enter} send`, 'esc cancel'];
|
||
case 'search':
|
||
return [`${glyphs.updown} results`, `${glyphs.enter} open`, 'type to search', 'esc close'];
|
||
case 'digest':
|
||
return ['j/k scroll', 'esc close'];
|
||
case 'list': {
|
||
if (!context.server) {
|
||
return [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', '? help', 'q quit'];
|
||
}
|
||
const keys = [`${glyphs.updown} select`, `${glyphs.enter} attach`];
|
||
if (context.approval === 'menu') keys.push('y approve', 'n deny', '1-9 option');
|
||
else keys.push('1-9 jump');
|
||
keys.push(context.approval === 'idle' ? 'p reply' : 'p prompt');
|
||
if (context.approval !== 'menu') keys.push('n new');
|
||
keys.push('x kill', '/ search', 'g digest', '? help', 'q quit');
|
||
return keys;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The help overlay's rows, for the same reason `footerKeysFor` exists: a help
|
||
* screen listing verbs the build does not implement is worse than no help.
|
||
*/
|
||
export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Array<[string, string]> {
|
||
const keys: Array<[string, string]> = [
|
||
[`${glyphs.updown} / j k`, 'select'],
|
||
[glyphs.enter, 'attach'],
|
||
['1-9', 'jump and attach'],
|
||
];
|
||
if (context.server) {
|
||
keys.push(
|
||
['y / n', 'approve or deny the selected dialog'],
|
||
['1-9', 'answer with that option, when a dialog is on screen'],
|
||
['p', 'send one line to the selected session'],
|
||
['/', 'search sessions, events and files'],
|
||
['g', 'away digest'],
|
||
['n', 'new session'],
|
||
['x', 'kill (typed confirmation)']
|
||
);
|
||
}
|
||
keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']);
|
||
return keys;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Preview policy (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export interface TuiPreviewContext {
|
||
mode: TuiUiMode;
|
||
/** Narrow layouts have no preview pane at all, so a poll would be wasted. */
|
||
narrow: boolean;
|
||
connection: TuiConnectionStatus;
|
||
row: TuiRow | null;
|
||
}
|
||
|
||
/**
|
||
* Is the selected row worth polling for a tail? Only a live session that is on
|
||
* screen with the list in focus qualifies: a history row has no buffer to read,
|
||
* and an overlay hides the pane it would repaint.
|
||
*/
|
||
export function shouldFetchPreview(context: TuiPreviewContext): boolean {
|
||
if (context.mode !== 'list' || context.narrow) return false;
|
||
if (context.connection === 'degraded' || context.connection === 'down') return false;
|
||
const row = context.row;
|
||
return row !== null && row.group !== 'recent';
|
||
}
|
||
|
||
/**
|
||
* The static line a pane shows instead of a tail, or null when a tail is on its
|
||
* way. Says what is true rather than "loading", which would never resolve.
|
||
*/
|
||
export function previewNoteFor(row: TuiRow | null, connection: TuiConnectionStatus): string | null {
|
||
if (!row) return null;
|
||
if (connection === 'degraded' || connection === 'down') return null;
|
||
if (row.group === 'recent') return 'this session is not running: no live output to show';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Would painting `next` change anything? The preview polls once a second, and a
|
||
* quiet session returns the same bytes every time; comparing here is what keeps
|
||
* that poll from bumping the model's revision and repainting the frame.
|
||
*/
|
||
export function samePreview(previous: TuiPreview | null, next: TuiPreview | null): boolean {
|
||
if (previous === next) return true;
|
||
if (!previous || !next) return false;
|
||
if (previous.sessionId !== next.sessionId) return false;
|
||
if (previous.error !== next.error || previous.note !== next.note) return false;
|
||
if (previous.lines.length !== next.lines.length) return false;
|
||
return previous.lines.every((line, i) => line === next.lines[i]);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Repaint policy (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** Everything a frame depends on, cheap enough to compare on every event. */
|
||
export interface TuiFrameKey {
|
||
revision: number;
|
||
cols: number;
|
||
rows: number;
|
||
tick: number;
|
||
}
|
||
|
||
export function sameFrame(previous: TuiFrameKey | null, next: TuiFrameKey): boolean {
|
||
return (
|
||
previous !== null &&
|
||
previous.revision === next.revision &&
|
||
previous.cols === next.cols &&
|
||
previous.rows === next.rows &&
|
||
previous.tick === next.tick
|
||
);
|
||
}
|
||
|
||
/** Only a WORKING row animates, so only a WORKING row justifies a tick timer. */
|
||
export function shouldAnimate(rows: readonly TuiRow[]): boolean {
|
||
return rows.some((row) => row.state === 'working');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Rows (pure)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* tmux sessions as dashboard rows, for when no server answered.
|
||
*
|
||
* They are tagged `live` deliberately: a running pane is the only liveness
|
||
* evidence that exists without a server, and the alternative (no `live` source)
|
||
* would classify every attachable session as history and file it under RECENT.
|
||
* With no classification running they land in IDLE, which is honest: unknown
|
||
* state, still attachable.
|
||
*/
|
||
export function tmuxRowsToSessions(sessions: readonly TuiTmuxSession[]): TuiSessionRow[] {
|
||
return sessions.map((session) => ({
|
||
sessionId: session.sessionId ?? session.muxName,
|
||
muxName: session.muxName,
|
||
sources: ['live', 'mux'],
|
||
...(session.name ? { name: session.name } : {}),
|
||
...(session.mode ? { mode: session.mode } : {}),
|
||
...(session.workingDir ? { workingDir: session.workingDir } : {}),
|
||
...(session.createdAt ? { createdAt: session.createdAt, lastActivityAt: session.createdAt } : {}),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Stamp each row with the tmux session that backs it. The mux name carries only
|
||
* the first 8 characters of the session id (`codeman-<prefix>`), so the join is
|
||
* by prefix; a row that matches nothing keeps no mux name and cannot be
|
||
* attached, which is exactly what the attach path then reports.
|
||
*/
|
||
export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly TuiTmuxSession[]): TuiSessionRow[] {
|
||
if (tmux.length === 0) return sessions.map((session) => ({ ...session }));
|
||
return sessions.map((session) => {
|
||
const match = tmux.find(
|
||
(entry) => entry.sessionId === session.sessionId || session.sessionId.startsWith(entry.sessionIdPrefix)
|
||
);
|
||
return match ? { ...session, muxName: match.muxName } : { ...session };
|
||
});
|
||
}
|
||
|
||
const STATE_TONE: Record<TuiSessionState, Tone> = {
|
||
'blocked-permission': 'err',
|
||
'blocked-question': 'err',
|
||
waiting: 'warn',
|
||
working: 'ok',
|
||
idle: 'idle',
|
||
recent: 'idle',
|
||
};
|
||
|
||
export interface TuiListLine {
|
||
/** 1-based position, the same number `codeman tui <n>` takes. */
|
||
index: number;
|
||
state: TuiSessionState;
|
||
label: string;
|
||
workingDir: string;
|
||
}
|
||
|
||
/**
|
||
* Label column cap. History rows are labelled by their opening prompt, and one
|
||
* long prompt pads every other row of the table out to its width.
|
||
*/
|
||
const LIST_LABEL_WIDTH = 48;
|
||
|
||
function truncateLabel(text: string, width: number): string {
|
||
return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`;
|
||
}
|
||
|
||
/** `--list` rows, in the dashboard's own order so `<n>` and the TUI agree. */
|
||
export function buildListLines(rows: readonly TuiRow[], labelWidth = LIST_LABEL_WIDTH): TuiListLine[] {
|
||
return rows.map((row, i) => ({
|
||
index: i + 1,
|
||
state: row.state,
|
||
label: truncateLabel(rowLabel(row.session), labelWidth),
|
||
workingDir: row.session.workingDir ?? '',
|
||
}));
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Session modes offered by the new-session picker
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
type TuiRunMode = NonNullable<TuiQuickStartOptions['mode']>;
|
||
|
||
const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string }> = [
|
||
{ id: 'claude', label: 'claude', detail: 'Claude Code' },
|
||
{ id: 'shell', label: 'shell', detail: 'plain shell' },
|
||
{ id: 'opencode', label: 'opencode', detail: 'OpenCode' },
|
||
{ id: 'codex', label: 'codex', detail: 'OpenAI Codex' },
|
||
{ id: 'gemini', label: 'gemini', detail: 'Google Gemini' },
|
||
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
|
||
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Terminal
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** The slices of stdin/stdout the TUI uses; `process.stdin`/`stdout` satisfy both. */
|
||
export interface TuiStdin extends NodeJS.EventEmitter {
|
||
isTTY?: boolean;
|
||
setRawMode?(mode: boolean): unknown;
|
||
resume(): unknown;
|
||
pause(): unknown;
|
||
}
|
||
|
||
export interface TuiStdout {
|
||
isTTY?: boolean;
|
||
columns?: number;
|
||
rows?: number;
|
||
write(chunk: string): unknown;
|
||
on(event: 'resize', listener: () => void): unknown;
|
||
off(event: 'resize', listener: () => void): unknown;
|
||
}
|
||
|
||
/**
|
||
* Alternate screen + raw mode, entered and left as one unit. `leave()` is
|
||
* idempotent and safe to call from a signal handler, an exit hook and the
|
||
* normal path in any order.
|
||
*/
|
||
class TerminalScreen {
|
||
private entered = false;
|
||
|
||
constructor(
|
||
private readonly stdin: TuiStdin,
|
||
private readonly stdout: TuiStdout,
|
||
private readonly onResize: () => void
|
||
) {}
|
||
|
||
get active(): boolean {
|
||
return this.entered;
|
||
}
|
||
|
||
enter(): void {
|
||
if (this.entered) return;
|
||
this.entered = true;
|
||
this.stdout.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}`);
|
||
if (this.stdin.isTTY) this.stdin.setRawMode?.(true);
|
||
this.stdin.resume();
|
||
this.stdout.on('resize', this.onResize);
|
||
}
|
||
|
||
leave(): void {
|
||
if (!this.entered) return;
|
||
this.entered = false;
|
||
this.stdout.off('resize', this.onResize);
|
||
if (this.stdin.isTTY) this.stdin.setRawMode?.(false);
|
||
this.stdin.pause();
|
||
this.stdout.write(`${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// The app
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export interface TuiRunOptions {
|
||
stdin?: TuiStdin;
|
||
stdout?: TuiStdout;
|
||
env?: NodeJS.ProcessEnv;
|
||
/** Injected by tests; the default builds one from the environment. */
|
||
client?: TuiClient;
|
||
/** Overrides chalk's detection. Chalk owns it everywhere else (see cli-style). */
|
||
color?: boolean;
|
||
}
|
||
|
||
interface PickerRuntime {
|
||
stage: 'case' | 'mode';
|
||
caseName?: string;
|
||
/** Unfiltered items; the model holds the filtered view the renderer paints. */
|
||
all: TuiPickerItem[];
|
||
}
|
||
|
||
class TuiApp {
|
||
private readonly model: TuiModelStore = createTuiModel();
|
||
private readonly parser: TuiKeyParser = createKeyParser();
|
||
private readonly screen: TerminalScreen;
|
||
private readonly stdin: TuiStdin;
|
||
private readonly stdout: TuiStdout;
|
||
private readonly env: NodeJS.ProcessEnv;
|
||
private readonly client: TuiClient;
|
||
private readonly color: boolean;
|
||
private readonly glyphTier: TuiGlyphTier;
|
||
private readonly glyphs: TuiGlyphSet;
|
||
private readonly socket = resolveTmuxSocketName();
|
||
|
||
private stream: TuiEventStream | null = null;
|
||
private tick = 0;
|
||
private lastFrame: TuiFrameKey | null = null;
|
||
private escTimer: NodeJS.Timeout | null = null;
|
||
private tickTimer: NodeJS.Timeout | null = null;
|
||
private resyncTimer: NodeJS.Timeout | null = null;
|
||
private pollTimer: NodeJS.Timeout | null = null;
|
||
private probeTimer: NodeJS.Timeout | null = null;
|
||
private previewTimer: NodeJS.Timeout | null = null;
|
||
private searchTimer: NodeJS.Timeout | null = null;
|
||
private noticeTimer: NodeJS.Timeout | null = null;
|
||
private refreshing = false;
|
||
private refreshQueued = false;
|
||
private picker: PickerRuntime | null = null;
|
||
private pendingSelectId: string | null = null;
|
||
/** Whose tail the preview is currently following; null when nothing is polled. */
|
||
private previewSessionId: string | null = null;
|
||
private previewFetching = false;
|
||
/** Bumped per search so a slow response cannot overwrite a newer query's results. */
|
||
private searchSeq = 0;
|
||
/** Approval ids the bell has already rung for. See `newApprovalIds`. */
|
||
private readonly seenApprovals = new Set<string>();
|
||
private exiting = false;
|
||
private resolveExit: ((code: number) => void) | null = null;
|
||
|
||
private readonly onData = (chunk: Buffer): void => this.feed(chunk);
|
||
private readonly onResize = (): void => this.paint(true);
|
||
private readonly onProcessExit = (): void => this.screen.leave();
|
||
private readonly onSignal = (): void => this.quit(0);
|
||
private readonly onFatal = (error: unknown): void => {
|
||
this.screen.leave();
|
||
process.stderr.write(`codeman tui: ${getErrorMessage(error)}\n`);
|
||
if (error instanceof Error && error.stack) process.stderr.write(`${error.stack}\n`);
|
||
process.exit(1);
|
||
};
|
||
|
||
constructor(options: TuiRunOptions) {
|
||
this.stdin = options.stdin ?? process.stdin;
|
||
this.stdout = options.stdout ?? process.stdout;
|
||
this.env = options.env ?? process.env;
|
||
this.client = options.client ?? new TuiClient();
|
||
this.color = options.color ?? chalk.level > 0;
|
||
this.glyphTier = detectGlyphTier(this.env);
|
||
this.glyphs = glyphsFor(this.glyphTier);
|
||
this.screen = new TerminalScreen(this.stdin, this.stdout, this.onResize);
|
||
}
|
||
|
||
async run(): Promise<number> {
|
||
const server = await this.client.connect();
|
||
if (server?.authRequired) {
|
||
this.client.close();
|
||
process.stderr.write(
|
||
`${palette.err('The Codeman server rejected these credentials.')}\n` +
|
||
`Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` +
|
||
'or put them in ~/.codeman/.env, then run codeman tui again.\n'
|
||
);
|
||
return 1;
|
||
}
|
||
|
||
if (server) {
|
||
this.model.setConnection('connected');
|
||
this.model.setHeader({
|
||
...(server.hostname ? { hostname: server.hostname } : {}),
|
||
...(server.instance ? { instance: server.instance } : {}),
|
||
...(server.version ? { version: server.version } : {}),
|
||
...(server.planUsage ? { planUsage: formatPlanUsage(server.planUsage) } : {}),
|
||
});
|
||
} else {
|
||
this.model.setConnection('degraded');
|
||
// No server to name the machine, and tmux is local by definition.
|
||
this.model.setHeader({
|
||
hostname: osHostname(),
|
||
...(CODEMAN_INSTANCE ? { instance: CODEMAN_INSTANCE } : {}),
|
||
});
|
||
this.startProbing();
|
||
}
|
||
|
||
this.installSafetyNets();
|
||
this.stdin.on('data', this.onData);
|
||
this.screen.enter();
|
||
this.paint(true);
|
||
|
||
await this.refresh();
|
||
if (server) this.subscribe();
|
||
|
||
return new Promise<number>((resolve) => {
|
||
this.resolveExit = resolve;
|
||
});
|
||
}
|
||
|
||
// ── Data ───────────────────────────────────────────────────────────────────
|
||
|
||
private subscribe(): void {
|
||
this.stream = this.client.subscribeEvents({
|
||
onInit: (state) => {
|
||
if (state.version) this.model.setHeader({ version: state.version });
|
||
if (state.planUsage) this.model.setHeader({ planUsage: formatPlanUsage(state.planUsage) });
|
||
this.paint();
|
||
},
|
||
onResync: () => this.scheduleRefresh(),
|
||
// The bell and the card both ride the refetch this schedules: the event
|
||
// carries the item, but the list has to be re-read anyway (an approval
|
||
// changes which group its row is in), and one code path cannot double-ring.
|
||
onApproval: () => this.scheduleRefresh(),
|
||
onPlanUsage: (usage) => {
|
||
this.model.setHeader({ planUsage: formatPlanUsage(usage) });
|
||
this.paint();
|
||
},
|
||
onStatus: (status, detail) => {
|
||
this.model.setConnection(status === 'connected' ? 'connected' : 'reconnecting');
|
||
if (detail.recommendPolling) this.startPolling();
|
||
else this.stopPolling();
|
||
if (status === 'connected') this.scheduleRefresh();
|
||
this.paint();
|
||
},
|
||
});
|
||
}
|
||
|
||
private scheduleRefresh(): void {
|
||
if (this.resyncTimer) return;
|
||
this.resyncTimer = setTimeout(() => {
|
||
this.resyncTimer = null;
|
||
void this.refresh();
|
||
}, RESYNC_DEBOUNCE_MS);
|
||
}
|
||
|
||
/**
|
||
* Re-read everything the dashboard shows. Overlapping calls collapse: a burst
|
||
* of events must not queue a burst of round trips, and the last one has to
|
||
* still run or the list would sit one change behind.
|
||
*/
|
||
private async refresh(): Promise<void> {
|
||
if (this.exiting) return;
|
||
if (this.refreshing) {
|
||
this.refreshQueued = true;
|
||
return;
|
||
}
|
||
this.refreshing = true;
|
||
try {
|
||
if (this.model.connection === 'degraded') await this.refreshDegraded();
|
||
else await this.refreshConnected();
|
||
} finally {
|
||
this.refreshing = false;
|
||
}
|
||
if (this.refreshQueued && !this.exiting) {
|
||
this.refreshQueued = false;
|
||
await this.refresh();
|
||
}
|
||
}
|
||
|
||
private async refreshConnected(): Promise<void> {
|
||
try {
|
||
const [sessions, approvals, tmux] = await Promise.all([
|
||
this.client.fetchUnifiedSessions(UNIFIED_LIMIT),
|
||
this.client.fetchApprovals().catch(() => []),
|
||
this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]),
|
||
]);
|
||
this.model.replaceSessions(applyMuxNames(sessions, tmux));
|
||
this.model.setApprovals(approvals);
|
||
this.noteApprovals(approvals);
|
||
if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null;
|
||
this.updatePreview();
|
||
this.paint();
|
||
} catch (error) {
|
||
// A failed refresh is a connection symptom, not a reason to lose the list:
|
||
// the rows on screen stay, the banner explains why they may be stale.
|
||
this.model.setConnection('reconnecting');
|
||
this.paint();
|
||
if (this.env.CODEMAN_TUI_DEBUG) process.stderr.write(`refresh failed: ${getErrorMessage(error)}\n`);
|
||
}
|
||
}
|
||
|
||
private async refreshDegraded(): Promise<void> {
|
||
const tmux = await this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]);
|
||
this.model.replaceSessions(tmuxRowsToSessions(tmux));
|
||
this.updatePreview();
|
||
this.paint();
|
||
}
|
||
|
||
/**
|
||
* Ring once for prompts that were not pending a moment ago. Answered ids stay
|
||
* in the set on purpose (the inbox restores a failed write under the SAME id),
|
||
* so the bell cannot stutter on one dialog.
|
||
*/
|
||
private noteApprovals(items: readonly ApprovalItem[]): void {
|
||
const fresh = newApprovalIds(this.seenApprovals, items);
|
||
if (fresh.length === 0) return;
|
||
for (const id of fresh) this.seenApprovals.add(id);
|
||
if (this.seenApprovals.size > SEEN_APPROVAL_CAP) {
|
||
this.seenApprovals.clear();
|
||
for (const item of items) this.seenApprovals.add(item.id);
|
||
}
|
||
this.stdout.write(BELL);
|
||
}
|
||
|
||
private startPolling(): void {
|
||
if (this.pollTimer) return;
|
||
this.pollTimer = setInterval(() => void this.refresh(), POLL_INTERVAL_MS);
|
||
}
|
||
|
||
private stopPolling(): void {
|
||
if (!this.pollTimer) return;
|
||
clearInterval(this.pollTimer);
|
||
this.pollTimer = null;
|
||
}
|
||
|
||
/** Degraded mode: re-probe so a server that comes up upgrades the TUI in place. */
|
||
private startProbing(): void {
|
||
if (this.probeTimer) return;
|
||
this.probeTimer = setInterval(() => void this.probe(), REPROBE_INTERVAL_MS);
|
||
}
|
||
|
||
private async probe(): Promise<void> {
|
||
if (this.exiting || this.model.connection !== 'degraded') return;
|
||
const server = await this.client.connect().catch(() => null);
|
||
if (!server || server.authRequired) {
|
||
await this.refresh();
|
||
return;
|
||
}
|
||
if (this.probeTimer) {
|
||
clearInterval(this.probeTimer);
|
||
this.probeTimer = null;
|
||
}
|
||
this.model.setConnection('connected');
|
||
this.model.setHeader({
|
||
...(server.hostname ? { hostname: server.hostname } : {}),
|
||
...(server.instance ? { instance: server.instance } : {}),
|
||
...(server.version ? { version: server.version } : {}),
|
||
});
|
||
await this.refresh();
|
||
this.subscribe();
|
||
}
|
||
|
||
// ── Preview ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Keep the preview pointed at the selected session: start polling when the
|
||
* selection is a live row with the list in focus, stop when it is not, and
|
||
* say why when there is nothing to poll.
|
||
*/
|
||
private updatePreview(): void {
|
||
const row = this.model.selectedSession();
|
||
const sessionId = row?.session.sessionId ?? null;
|
||
const changed = sessionId !== this.previewSessionId;
|
||
this.previewSessionId = sessionId;
|
||
|
||
const wanted = shouldFetchPreview({
|
||
mode: this.model.mode,
|
||
narrow: this.currentLayout().narrow,
|
||
connection: this.model.connection,
|
||
row,
|
||
});
|
||
|
||
if (!wanted) {
|
||
this.stopPreview();
|
||
const note = previewNoteFor(row, this.model.connection);
|
||
if (row && note) this.applyPreview({ sessionId: row.session.sessionId, lines: [], note });
|
||
else if (changed) this.applyPreview(null);
|
||
return;
|
||
}
|
||
|
||
if (changed) {
|
||
// Null rather than an empty tail: the renderer reads that as "loading",
|
||
// while empty lines would claim the session has printed nothing.
|
||
this.applyPreview(null);
|
||
void this.fetchPreview();
|
||
}
|
||
if (!this.previewTimer) {
|
||
this.previewTimer = setInterval(() => void this.fetchPreview(), PREVIEW_INTERVAL_MS);
|
||
}
|
||
}
|
||
|
||
private stopPreview(): void {
|
||
if (!this.previewTimer) return;
|
||
clearInterval(this.previewTimer);
|
||
this.previewTimer = null;
|
||
}
|
||
|
||
private applyPreview(preview: TuiPreview | null): void {
|
||
if (samePreview(this.model.preview, preview)) return;
|
||
this.model.setPreview(preview);
|
||
}
|
||
|
||
private async fetchPreview(): Promise<void> {
|
||
const sessionId = this.previewSessionId;
|
||
if (!sessionId || this.previewFetching || this.exiting) return;
|
||
this.previewFetching = true;
|
||
try {
|
||
const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES);
|
||
if (this.previewSessionId !== sessionId) return;
|
||
this.applyPreview({ sessionId, lines: toDisplayLines(raw).slice(-PREVIEW_MAX_LINES) });
|
||
} catch {
|
||
// A tail that cannot be read is a pane-level fact, not a connection one:
|
||
// the list stays exactly as it is and only this pane says so.
|
||
if (this.previewSessionId !== sessionId) return;
|
||
this.applyPreview({ sessionId, lines: [], error: 'could not read this session’s terminal' });
|
||
} finally {
|
||
this.previewFetching = false;
|
||
}
|
||
this.paint();
|
||
}
|
||
|
||
// ── Painting ───────────────────────────────────────────────────────────────
|
||
|
||
private currentLayout(): TuiLayout {
|
||
return computeLayout(this.stdout.columns ?? 80, this.stdout.rows ?? 24, {
|
||
banner: needsBanner(this.model.connection),
|
||
});
|
||
}
|
||
|
||
private keymapContext(): TuiKeymapContext {
|
||
const approval = this.model.selectedSession()?.approval;
|
||
return {
|
||
server: this.model.connection !== 'degraded',
|
||
approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null,
|
||
};
|
||
}
|
||
|
||
private paint(force = false): void {
|
||
if (this.exiting || !this.screen.active) return;
|
||
const layout = this.currentLayout();
|
||
const key: TuiFrameKey = {
|
||
revision: this.model.revision,
|
||
cols: layout.cols,
|
||
rows: layout.rows,
|
||
tick: this.tick,
|
||
};
|
||
if (!force && sameFrame(this.lastFrame, key)) return;
|
||
this.lastFrame = key;
|
||
|
||
const keymap = this.keymapContext();
|
||
const options: TuiRenderOptions = {
|
||
color: this.color,
|
||
glyphs: this.glyphTier,
|
||
tick: this.tick,
|
||
now: Date.now(),
|
||
footerKeys: footerKeysFor(this.model.mode, this.glyphs, keymap),
|
||
helpKeys: helpKeysFor(this.glyphs, keymap),
|
||
};
|
||
// The cursor belongs in the composer while one is open and nowhere else: a
|
||
// blinking cursor parked in a dashboard reads as a stuck program.
|
||
const cursor = composerCursorCell(this.model, layout);
|
||
const place = cursor ? `\x1b[${cursor.row};${cursor.col}H${CURSOR_SHOW}` : CURSOR_HIDE;
|
||
this.stdout.write(`${SYNC_BEGIN}${renderFrame(this.model, layout, options)}${place}${SYNC_END}`);
|
||
this.syncAnimation();
|
||
}
|
||
|
||
private syncAnimation(): void {
|
||
const wanted = shouldAnimate(this.model.rows());
|
||
if (wanted && !this.tickTimer) {
|
||
this.tickTimer = setInterval(() => {
|
||
this.tick++;
|
||
this.paint();
|
||
}, TICK_MS);
|
||
return;
|
||
}
|
||
if (!wanted && this.tickTimer) {
|
||
clearInterval(this.tickTimer);
|
||
this.tickTimer = null;
|
||
}
|
||
}
|
||
|
||
// ── Input ──────────────────────────────────────────────────────────────────
|
||
|
||
private feed(chunk: Buffer): void {
|
||
for (const event of this.parser.feed(chunk)) this.handle(event);
|
||
if (this.escTimer) {
|
||
clearTimeout(this.escTimer);
|
||
this.escTimer = null;
|
||
}
|
||
// A held ESC is either a lone Escape or the head of a sequence still in
|
||
// flight; only silence tells the two apart.
|
||
if (this.parser.pending() > 0) {
|
||
this.escTimer = setTimeout(() => {
|
||
this.escTimer = null;
|
||
for (const event of this.parser.flush()) this.handle(event);
|
||
this.afterInput();
|
||
}, ESC_FLUSH_MS);
|
||
}
|
||
this.afterInput();
|
||
}
|
||
|
||
/** Every key can change the selection or the mode, and both steer the preview. */
|
||
private afterInput(): void {
|
||
if (this.exiting) return;
|
||
this.updatePreview();
|
||
this.paint();
|
||
}
|
||
|
||
private handle(event: TuiInputEvent): void {
|
||
if (this.exiting) return;
|
||
switch (this.model.mode) {
|
||
case 'confirm-kill':
|
||
this.handleConfirm(event);
|
||
return;
|
||
case 'new-session':
|
||
this.handlePicker(event);
|
||
return;
|
||
case 'prompt':
|
||
this.handlePrompt(event);
|
||
return;
|
||
case 'search':
|
||
this.handleSearch(event);
|
||
return;
|
||
case 'digest':
|
||
this.handleDigest(event);
|
||
return;
|
||
case 'help':
|
||
case 'message':
|
||
// Any key dismisses; the footer says esc because that is the one key
|
||
// every overlay in the app answers to.
|
||
if (event.type !== 'mouse') this.model.closeOverlay();
|
||
return;
|
||
default:
|
||
this.handleList(event);
|
||
}
|
||
}
|
||
|
||
private handleList(event: TuiInputEvent): void {
|
||
switch (event.type) {
|
||
case 'key':
|
||
if (event.name === 'up') this.model.moveCursor(-1);
|
||
else if (event.name === 'down') this.model.moveCursor(1);
|
||
else if (event.name === 'pageup') this.model.moveCursor(-5);
|
||
else if (event.name === 'pagedown') this.model.moveCursor(5);
|
||
return;
|
||
case 'enter':
|
||
void this.attachSelected();
|
||
return;
|
||
case 'ctrl':
|
||
if (event.key === 'c') this.quit(0);
|
||
return;
|
||
case 'char':
|
||
this.handleListChar(event.value);
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private handleListChar(value: string): void {
|
||
// A pending dialog takes the keys it can answer, and only those: the mapping
|
||
// returns null for a digit the dialog has no option for (and for every key
|
||
// on an idle prompt), which leaves the list's own bindings intact.
|
||
const approval = this.model.selectedSession()?.approval;
|
||
if (approval) {
|
||
const answer = approvalAnswerForKey(approval, value);
|
||
if (answer) {
|
||
void this.answerApproval(approval, answer);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (value >= '1' && value <= '9') {
|
||
if (this.model.cursorToIndex(Number.parseInt(value, 10))) void this.attachSelected();
|
||
return;
|
||
}
|
||
switch (value) {
|
||
case 'j':
|
||
this.model.moveCursor(1);
|
||
return;
|
||
case 'k':
|
||
this.model.moveCursor(-1);
|
||
return;
|
||
case 'q':
|
||
this.quit(0);
|
||
return;
|
||
case '?':
|
||
this.model.setMode('help');
|
||
return;
|
||
case 'x':
|
||
this.beginKill();
|
||
return;
|
||
case 'n':
|
||
void this.openNewSession();
|
||
return;
|
||
case 'p':
|
||
this.openPrompt();
|
||
return;
|
||
case '/':
|
||
this.openSearch();
|
||
return;
|
||
case 'g':
|
||
void this.openDigest();
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private handleConfirm(event: TuiInputEvent): void {
|
||
const state = this.model.confirm;
|
||
if (!state) {
|
||
this.model.closeOverlay();
|
||
return;
|
||
}
|
||
const step = confirmKillStep(state, event);
|
||
switch (step.kind) {
|
||
case 'typing':
|
||
this.model.setConfirmInput(step.typed);
|
||
return;
|
||
case 'cancel':
|
||
this.model.closeOverlay();
|
||
return;
|
||
case 'reject':
|
||
this.message('warn', `type "${state.name}" exactly, or esc to cancel`);
|
||
return;
|
||
case 'confirm':
|
||
void this.killSession(state.sessionId, state.name);
|
||
return;
|
||
case 'ignore':
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ── Composer, search and digest input ──────────────────────────────────────
|
||
|
||
/** Columns the composer's text gets, once its fixed prefix is paid for. */
|
||
private composerWidth(): number {
|
||
return Math.max(1, (this.stdout.columns ?? 80) - COMPOSER_PREFIX.length);
|
||
}
|
||
|
||
private handlePrompt(event: TuiInputEvent): void {
|
||
const state = this.model.prompt;
|
||
if (!state) {
|
||
this.model.closeOverlay();
|
||
return;
|
||
}
|
||
const step = composerStep(state.composer, event);
|
||
switch (step.kind) {
|
||
case 'edit':
|
||
this.model.updatePrompt(this.scrolled(step.state));
|
||
return;
|
||
case 'cancel':
|
||
this.model.closeOverlay();
|
||
return;
|
||
case 'submit':
|
||
void this.sendPrompt(state.sessionId, step.text);
|
||
return;
|
||
case 'ignore':
|
||
return;
|
||
}
|
||
}
|
||
|
||
private scrolled(state: TuiComposerState): TuiComposerState {
|
||
return composerScroll(state, this.composerWidth());
|
||
}
|
||
|
||
private handleSearch(event: TuiInputEvent): void {
|
||
const state = this.model.search;
|
||
if (!state) {
|
||
this.model.closeOverlay();
|
||
return;
|
||
}
|
||
// The arrows drive the RESULT list, not the query caret: the query renders
|
||
// its caret as a trailing underscore, so a caret that could move would move
|
||
// invisibly.
|
||
if (event.type === 'key') {
|
||
if (event.name === 'up') this.moveSearch(-1);
|
||
else if (event.name === 'down') this.moveSearch(1);
|
||
else if (event.name === 'pageup') this.moveSearch(-5);
|
||
else if (event.name === 'pagedown') this.moveSearch(5);
|
||
return;
|
||
}
|
||
if (event.type === 'enter') {
|
||
this.openSearchResult();
|
||
return;
|
||
}
|
||
const step = composerStep(state.composer, event);
|
||
switch (step.kind) {
|
||
case 'edit':
|
||
this.model.updateSearch({ composer: step.state });
|
||
this.scheduleSearch(composerText(step.state));
|
||
return;
|
||
case 'cancel':
|
||
this.closeSearch();
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private moveSearch(delta: number): void {
|
||
const state = this.model.search;
|
||
if (!state || state.entries.length === 0) return;
|
||
this.model.updateSearch({ index: moveSearchIndex(state.entries, state.index, delta) });
|
||
}
|
||
|
||
private handleDigest(event: TuiInputEvent): void {
|
||
const capacity = digestCapacity(this.currentLayout());
|
||
const page = Math.max(1, capacity - 1);
|
||
switch (event.type) {
|
||
case 'escape':
|
||
this.closeOverlayAndResume();
|
||
return;
|
||
case 'ctrl':
|
||
if (event.key === 'c') this.closeOverlayAndResume();
|
||
return;
|
||
case 'key':
|
||
if (event.name === 'up') this.model.scrollDigest(-1, capacity);
|
||
else if (event.name === 'down') this.model.scrollDigest(1, capacity);
|
||
else if (event.name === 'pageup') this.model.scrollDigest(-page, capacity);
|
||
else if (event.name === 'pagedown') this.model.scrollDigest(page, capacity);
|
||
else if (event.name === 'home') this.model.scrollDigest(-Number.MAX_SAFE_INTEGER, capacity);
|
||
else if (event.name === 'end') this.model.scrollDigest(Number.MAX_SAFE_INTEGER, capacity);
|
||
return;
|
||
case 'char':
|
||
if (event.value === 'j') this.model.scrollDigest(1, capacity);
|
||
else if (event.value === 'k') this.model.scrollDigest(-1, capacity);
|
||
else if (event.value === 'q' || event.value === 'g') this.closeOverlayAndResume();
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ── Actions ────────────────────────────────────────────────────────────────
|
||
|
||
private message(tone: 'info' | 'warn' | 'err', text: string): void {
|
||
this.model.setMessage({ tone, text });
|
||
}
|
||
|
||
/**
|
||
* A message that clears itself. Used for outcomes the user already expects
|
||
* ("sent"), where a box waiting to be dismissed is one keystroke of ceremony
|
||
* for no information.
|
||
*/
|
||
private notice(text: string): void {
|
||
this.model.setMessage({ tone: 'info', text });
|
||
const shown = this.model.message;
|
||
if (this.noticeTimer) clearTimeout(this.noticeTimer);
|
||
this.noticeTimer = setTimeout(() => {
|
||
this.noticeTimer = null;
|
||
// Only clear the notice this timer armed: anything the user opened in the
|
||
// meantime owns the screen now.
|
||
if (this.model.message !== shown) return;
|
||
this.closeOverlayAndResume();
|
||
}, NOTICE_MS);
|
||
}
|
||
|
||
/** Drop the overlay and let the preview start following the list again. */
|
||
private closeOverlayAndResume(): void {
|
||
this.model.closeOverlay();
|
||
this.updatePreview();
|
||
this.paint();
|
||
}
|
||
|
||
private async answerApproval(item: ApprovalItem, answer: TuiApprovalAnswer): Promise<void> {
|
||
let result;
|
||
try {
|
||
result = await this.client.answerApproval(item.id, answer);
|
||
} catch (error) {
|
||
this.message('err', `could not answer that prompt: ${getErrorMessage(error)}`);
|
||
this.paint();
|
||
return;
|
||
}
|
||
await this.refresh();
|
||
if (result.ok) this.notice(`answered ${item.sessionName || item.sessionId.slice(0, 8)}`);
|
||
// The server re-captures the pane before it types, so this is the normal
|
||
// outcome when the dialog was answered in tmux a moment ago.
|
||
else if (result.reason === 'gone') this.message('warn', 'that dialog is no longer on screen');
|
||
else this.message('err', result.message);
|
||
this.paint();
|
||
}
|
||
|
||
private openPrompt(): void {
|
||
const row = this.model.selectedSession();
|
||
if (!row) return;
|
||
if (this.model.connection === 'degraded') {
|
||
this.message('warn', 'sending a prompt needs the server; only attach works while it is down');
|
||
return;
|
||
}
|
||
if (row.group === 'recent') {
|
||
this.message('warn', 'that session is not running: there is nothing to type at');
|
||
return;
|
||
}
|
||
this.model.setPrompt({
|
||
sessionId: row.session.sessionId,
|
||
label: rowLabel(row.session),
|
||
composer: createComposer(),
|
||
});
|
||
}
|
||
|
||
private async sendPrompt(sessionId: string, text: string): Promise<void> {
|
||
const line = text.trim();
|
||
this.model.closeOverlay();
|
||
if (line === '') {
|
||
this.updatePreview();
|
||
this.paint();
|
||
return;
|
||
}
|
||
try {
|
||
await this.client.sendInput(sessionId, line);
|
||
await this.refresh();
|
||
this.notice('sent');
|
||
} catch (error) {
|
||
this.message('err', `could not send that prompt: ${getErrorMessage(error)}`);
|
||
}
|
||
this.updatePreview();
|
||
this.paint();
|
||
}
|
||
|
||
private openSearch(): void {
|
||
if (this.model.connection === 'degraded') {
|
||
this.message('warn', 'search needs the server; only attach works while it is down');
|
||
return;
|
||
}
|
||
this.model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' });
|
||
}
|
||
|
||
private closeSearch(): void {
|
||
if (this.searchTimer) {
|
||
clearTimeout(this.searchTimer);
|
||
this.searchTimer = null;
|
||
}
|
||
this.closeOverlayAndResume();
|
||
}
|
||
|
||
private scheduleSearch(query: string): void {
|
||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||
this.searchTimer = setTimeout(() => {
|
||
this.searchTimer = null;
|
||
void this.runSearch(query);
|
||
}, SEARCH_DEBOUNCE_MS);
|
||
}
|
||
|
||
private async runSearch(query: string): Promise<void> {
|
||
if (this.model.mode !== 'search' || !this.model.search) return;
|
||
const needle = query.trim();
|
||
const seq = ++this.searchSeq;
|
||
if (needle === '') {
|
||
this.model.updateSearch({ query: '', entries: [], index: -1, status: 'idle', note: undefined });
|
||
this.paint();
|
||
return;
|
||
}
|
||
|
||
this.model.updateSearch({ status: 'searching', note: 'searching…' });
|
||
this.paint();
|
||
try {
|
||
const data = await this.client.search(needle, SEARCH_LIMIT);
|
||
if (seq !== this.searchSeq || this.model.mode !== 'search') return;
|
||
// Only a session that is on the list can be selected; a history hit has a
|
||
// session id but no row to move the cursor to.
|
||
const live = new Set(
|
||
this.model
|
||
.rows()
|
||
.filter((row) => row.group !== 'recent')
|
||
.map((row) => row.session.sessionId)
|
||
);
|
||
const entries = buildSearchEntries(data.groups, (id) => live.has(id));
|
||
this.model.updateSearch({
|
||
query: needle,
|
||
entries,
|
||
index: firstSearchIndex(entries),
|
||
status: 'done',
|
||
note:
|
||
entries.length === 0
|
||
? 'no matches'
|
||
: `${data.totalResults} result${data.totalResults === 1 ? '' : 's'}${data.truncated ? ' (capped)' : ''}`,
|
||
});
|
||
} catch (error) {
|
||
if (seq !== this.searchSeq || this.model.mode !== 'search') return;
|
||
this.model.updateSearch({
|
||
status: 'error',
|
||
entries: [],
|
||
index: -1,
|
||
note: `search failed: ${getErrorMessage(error)}`,
|
||
});
|
||
}
|
||
this.paint();
|
||
}
|
||
|
||
private openSearchResult(): void {
|
||
const state = this.model.search;
|
||
if (!state) return;
|
||
const entry = state.entries[state.index];
|
||
if (!entry || entry.kind !== 'result') return;
|
||
if (entry.live && entry.sessionId && this.model.select(entry.sessionId)) {
|
||
this.closeSearch();
|
||
return;
|
||
}
|
||
// Nothing to switch to (a history or file hit), so the row's own facts are
|
||
// the answer; resuming one is phase 3.
|
||
this.model.updateSearch({ note: [entry.text, entry.detail].filter((part) => part).join(' — ') });
|
||
}
|
||
|
||
private async openDigest(): Promise<void> {
|
||
if (this.model.connection === 'degraded') {
|
||
this.message('warn', 'the digest needs the server; only attach works while it is down');
|
||
return;
|
||
}
|
||
this.model.setDigest({ title: 'Away digest', lines: ['loading…'], offset: 0 });
|
||
this.updatePreview();
|
||
this.paint();
|
||
let lines: string[];
|
||
try {
|
||
const digest = await this.client.fetchAwayDigest();
|
||
lines = formatAwayDigest(digest, { now: Date.now() });
|
||
} catch (error) {
|
||
lines = [`could not load the digest: ${getErrorMessage(error)}`];
|
||
}
|
||
// Esc works throughout the round trip, and a digest that lands afterwards
|
||
// must not reopen the overlay the user just closed.
|
||
if (this.model.mode !== 'digest') return;
|
||
this.model.setDigest({ title: 'Away digest', lines, offset: 0 });
|
||
this.paint();
|
||
}
|
||
|
||
private async attachSelected(): Promise<void> {
|
||
const row = this.model.selectedSession();
|
||
if (!row) return;
|
||
if (row.group === 'recent') {
|
||
this.message('warn', 'that session is not running; resuming a past session is not wired up yet');
|
||
return;
|
||
}
|
||
const plan = planAttach(row.session.muxName, {
|
||
socket: this.socket,
|
||
...(this.env.TMUX ? { tmux: this.env.TMUX } : {}),
|
||
});
|
||
if (plan.kind === 'refuse') {
|
||
this.message('warn', plan.message);
|
||
return;
|
||
}
|
||
|
||
if (plan.kind === 'switch') {
|
||
// The client this TUI draws on is about to show another session, so the
|
||
// dashboard has nothing left to draw and no reason to keep polling.
|
||
this.screen.leave();
|
||
const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' });
|
||
if (result.error) {
|
||
process.stderr.write(`codeman tui: ${getErrorMessage(result.error)}\n`);
|
||
this.quit(1);
|
||
return;
|
||
}
|
||
this.quit(result.status ?? 0);
|
||
return;
|
||
}
|
||
|
||
this.screen.leave();
|
||
this.stdout.write(`${plan.hint}\n`);
|
||
const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' });
|
||
this.screen.enter();
|
||
this.paint(true);
|
||
if (result.error) {
|
||
this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`);
|
||
return;
|
||
}
|
||
await this.refresh();
|
||
this.paint(true);
|
||
}
|
||
|
||
private beginKill(): void {
|
||
const row = this.model.selectedSession();
|
||
if (!row) return;
|
||
if (this.model.connection === 'degraded') {
|
||
this.message('warn', 'killing a session needs the server; only attach works while it is down');
|
||
return;
|
||
}
|
||
if (row.group === 'recent') {
|
||
this.message('warn', 'that row is history: there is no session left to kill');
|
||
return;
|
||
}
|
||
if (isSelfSession(row.session.sessionId, this.env)) {
|
||
this.message('warn', 'that is the session this TUI is running in');
|
||
return;
|
||
}
|
||
this.model.beginConfirmKill(row);
|
||
}
|
||
|
||
private async killSession(sessionId: string, name: string): Promise<void> {
|
||
this.model.closeOverlay();
|
||
try {
|
||
await this.client.deleteSession(sessionId);
|
||
await this.refresh();
|
||
this.message('info', `killed ${name}`);
|
||
} catch (error) {
|
||
this.message('err', `could not kill ${name}: ${getErrorMessage(error)}`);
|
||
}
|
||
this.paint();
|
||
}
|
||
|
||
private async openNewSession(): Promise<void> {
|
||
if (this.model.connection === 'degraded') {
|
||
this.message('warn', 'starting a session needs the server; only attach works while it is down');
|
||
return;
|
||
}
|
||
this.picker = { stage: 'case', all: [] };
|
||
this.model.setPicker({ title: 'New session', items: [], index: -1, filter: '', hint: 'loading cases…' });
|
||
this.paint();
|
||
|
||
let items: TuiPickerItem[];
|
||
try {
|
||
const cases = await this.client.fetchCases();
|
||
items = cases.map((entry) => ({
|
||
id: entry.name,
|
||
label: entry.name,
|
||
...(entry.location && entry.location !== 'local' ? { detail: entry.location } : {}),
|
||
}));
|
||
} catch (error) {
|
||
this.picker = null;
|
||
this.message('err', `could not list cases: ${getErrorMessage(error)}`);
|
||
this.paint();
|
||
return;
|
||
}
|
||
|
||
// The picker can be gone already: fetching cases takes a round trip and esc
|
||
// works throughout it.
|
||
if (!this.picker || this.picker.stage !== 'case') return;
|
||
this.picker.all = items;
|
||
this.showPicker('Pick a case', items.length > 0 ? 'which case should the session run in?' : 'no cases found');
|
||
this.paint();
|
||
}
|
||
|
||
private showPicker(title: string, hint: string, filter = ''): void {
|
||
const runtime = this.picker;
|
||
if (!runtime) return;
|
||
const needle = filter.trim().toLowerCase();
|
||
const items = needle ? runtime.all.filter((item) => item.label.toLowerCase().includes(needle)) : [...runtime.all];
|
||
this.model.setPicker({ title, items, index: items.length > 0 ? 0 : -1, filter, hint });
|
||
}
|
||
|
||
private handlePicker(event: TuiInputEvent): void {
|
||
const state = this.model.picker;
|
||
const runtime = this.picker;
|
||
if (!state || !runtime) {
|
||
this.model.closeOverlay();
|
||
return;
|
||
}
|
||
switch (event.type) {
|
||
case 'escape':
|
||
this.picker = null;
|
||
this.model.closeOverlay();
|
||
return;
|
||
case 'ctrl':
|
||
if (event.key === 'c') {
|
||
this.picker = null;
|
||
this.model.closeOverlay();
|
||
}
|
||
return;
|
||
case 'key':
|
||
if (event.name === 'up') this.movePicker(-1);
|
||
else if (event.name === 'down') this.movePicker(1);
|
||
return;
|
||
case 'backspace':
|
||
this.showPicker(state.title, state.hint ?? '', [...(state.filter ?? '')].slice(0, -1).join(''));
|
||
return;
|
||
case 'char':
|
||
this.showPicker(state.title, state.hint ?? '', `${state.filter ?? ''}${event.value}`);
|
||
return;
|
||
case 'enter':
|
||
this.choosePicked();
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private movePicker(delta: number): void {
|
||
const state = this.model.picker;
|
||
if (!state || state.items.length === 0) return;
|
||
const next = (((state.index + delta) % state.items.length) + state.items.length) % state.items.length;
|
||
this.model.setPicker({ ...state, index: next });
|
||
}
|
||
|
||
private choosePicked(): void {
|
||
const state = this.model.picker;
|
||
const runtime = this.picker;
|
||
if (!state || !runtime || state.index < 0 || state.index >= state.items.length) return;
|
||
const chosen = state.items[state.index];
|
||
|
||
if (runtime.stage === 'case') {
|
||
this.picker = {
|
||
stage: 'mode',
|
||
caseName: chosen.id,
|
||
all: MODE_ITEMS.map((mode) => ({ id: mode.id, label: mode.label, detail: mode.detail })),
|
||
};
|
||
this.showPicker('Pick a CLI', `new session in ${chosen.label}`);
|
||
return;
|
||
}
|
||
|
||
const caseName = runtime.caseName;
|
||
// Resolved against the table rather than cast: the picker's ids are strings
|
||
// and quick-start refuses a mode the server does not know.
|
||
const mode = MODE_ITEMS.find((entry) => entry.id === chosen.id);
|
||
this.picker = null;
|
||
this.model.closeOverlay();
|
||
if (caseName && mode) void this.startSession(caseName, mode.id);
|
||
}
|
||
|
||
private async startSession(caseName: string, mode: TuiRunMode): Promise<void> {
|
||
try {
|
||
const result = await this.client.quickStart({ caseName, mode });
|
||
// The row appears with the next resync; remember which one to select.
|
||
this.pendingSelectId = result.sessionId;
|
||
this.message('info', `started ${mode} in ${caseName}`);
|
||
await this.refresh();
|
||
} catch (error) {
|
||
this.message('err', `could not start a session in ${caseName}: ${getErrorMessage(error)}`);
|
||
}
|
||
this.paint();
|
||
}
|
||
|
||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||
|
||
private installSafetyNets(): void {
|
||
process.on('exit', this.onProcessExit);
|
||
process.on('SIGINT', this.onSignal);
|
||
process.on('SIGTERM', this.onSignal);
|
||
// Prepended: src/index.ts already handles both by exiting, and a listener
|
||
// registered after it would never run, leaving the terminal in raw mode.
|
||
process.prependListener('uncaughtException', this.onFatal);
|
||
process.prependListener('unhandledRejection', this.onFatal);
|
||
}
|
||
|
||
private removeSafetyNets(): void {
|
||
process.off('exit', this.onProcessExit);
|
||
process.off('SIGINT', this.onSignal);
|
||
process.off('SIGTERM', this.onSignal);
|
||
process.off('uncaughtException', this.onFatal);
|
||
process.off('unhandledRejection', this.onFatal);
|
||
}
|
||
|
||
private quit(code: number): void {
|
||
if (this.exiting) return;
|
||
this.exiting = true;
|
||
for (const timer of [this.escTimer, this.resyncTimer, this.searchTimer, this.noticeTimer]) {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer, this.previewTimer]) {
|
||
if (timer) clearInterval(timer);
|
||
}
|
||
this.escTimer = null;
|
||
this.resyncTimer = null;
|
||
this.searchTimer = null;
|
||
this.noticeTimer = null;
|
||
this.tickTimer = null;
|
||
this.pollTimer = null;
|
||
this.probeTimer = null;
|
||
this.previewTimer = null;
|
||
this.stream?.close();
|
||
this.client.close();
|
||
this.stdin.off('data', this.onData);
|
||
this.screen.leave();
|
||
this.removeSafetyNets();
|
||
this.resolveExit?.(code);
|
||
this.resolveExit = null;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Entry points
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
type Snapshot = { kind: 'ok'; rows: TuiRow[]; degraded: boolean } | { kind: 'auth' };
|
||
|
||
/**
|
||
* One read of the world for the non-interactive paths. Same client, same
|
||
* classification and same ordering as the dashboard, so `--list`'s numbers are
|
||
* the numbers `codeman tui <n>` takes.
|
||
*/
|
||
async function snapshot(client: TuiClient): Promise<Snapshot> {
|
||
const server = await client.connect();
|
||
if (server?.authRequired) return { kind: 'auth' };
|
||
const model = createTuiModel();
|
||
if (!server) {
|
||
model.replaceSessions(tmuxRowsToSessions(await client.enumerateTmuxSessions()));
|
||
return { kind: 'ok', rows: model.rows(), degraded: true };
|
||
}
|
||
const [sessions, approvals, tmux] = await Promise.all([
|
||
client.fetchUnifiedSessions(UNIFIED_LIMIT),
|
||
client.fetchApprovals().catch(() => []),
|
||
client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]),
|
||
]);
|
||
model.replaceSessions(applyMuxNames(sessions, tmux));
|
||
model.setApprovals(approvals);
|
||
return { kind: 'ok', rows: model.rows(), degraded: false };
|
||
}
|
||
|
||
function authHint(): string {
|
||
return (
|
||
`${palette.err('The Codeman server rejected these credentials.')}\n` +
|
||
`Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` +
|
||
'or put them in ~/.codeman/.env.\n'
|
||
);
|
||
}
|
||
|
||
/** The full-screen dashboard. */
|
||
export async function runTui(options: TuiRunOptions = {}): Promise<number> {
|
||
const stdin = options.stdin ?? process.stdin;
|
||
const stdout = options.stdout ?? process.stdout;
|
||
if (!stdout.isTTY || !stdin.isTTY) {
|
||
process.stderr.write(
|
||
`${palette.err('codeman tui needs an interactive terminal.')}\n` +
|
||
`Use ${palette.info('codeman tui --list')} for a plain list, or ${palette.info('codeman tui <n>')} to attach.\n`
|
||
);
|
||
return 1;
|
||
}
|
||
return new TuiApp(options).run();
|
||
}
|
||
|
||
/**
|
||
* `codeman tui --list`: the `sc -l` replacement. Prints and exits, colored when
|
||
* the output is a terminal and plain when it is piped (chalk's call, not ours).
|
||
*/
|
||
export async function runTuiList(options: TuiRunOptions = {}): Promise<number> {
|
||
const stdout = options.stdout ?? process.stdout;
|
||
const client = options.client ?? new TuiClient();
|
||
try {
|
||
const state = await snapshot(client).catch((error: unknown) => {
|
||
process.stderr.write(`${palette.err(getErrorMessage(error))}\n`);
|
||
return null;
|
||
});
|
||
if (!state) return 1;
|
||
if (state.kind === 'auth') {
|
||
process.stderr.write(authHint());
|
||
return 1;
|
||
}
|
||
if (state.degraded) {
|
||
process.stderr.write(`${palette.warn('server not running: listing tmux sessions only')}\n`);
|
||
}
|
||
const lines = buildListLines(state.rows);
|
||
if (lines.length === 0) {
|
||
process.stderr.write(`${palette.muted('no sessions')}\n`);
|
||
return 0;
|
||
}
|
||
const rows = lines.map((line) => [
|
||
palette.muted(String(line.index)),
|
||
tint(STATE_TONE[line.state], STATE_WORDS[line.state]),
|
||
line.label,
|
||
palette.muted(line.workingDir),
|
||
]);
|
||
stdout.write(`${table(rows, { indent: ' ' })}\n`);
|
||
return 0;
|
||
} finally {
|
||
client.close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `codeman tui <n>`: the `sc 2` replacement. No screen setup at all, so it is
|
||
* as fast as the API call it makes.
|
||
*/
|
||
export async function runTuiAttach(position: number, options: TuiRunOptions = {}): Promise<number> {
|
||
const stdin = options.stdin ?? process.stdin;
|
||
const stdout = options.stdout ?? process.stdout;
|
||
if (!stdout.isTTY || !stdin.isTTY) {
|
||
process.stderr.write(`${palette.err('attaching needs an interactive terminal.')}\n`);
|
||
return 1;
|
||
}
|
||
const env = options.env ?? process.env;
|
||
const client = options.client ?? new TuiClient();
|
||
try {
|
||
const state = await snapshot(client).catch((error: unknown) => {
|
||
process.stderr.write(`${palette.err(getErrorMessage(error))}\n`);
|
||
return null;
|
||
});
|
||
if (!state) return 1;
|
||
if (state.kind === 'auth') {
|
||
process.stderr.write(authHint());
|
||
return 1;
|
||
}
|
||
const row = state.rows[Math.trunc(position) - 1];
|
||
if (!row) {
|
||
process.stderr.write(
|
||
`${palette.err(`there is no session ${position}`)}\nRun ${palette.info('codeman tui --list')} to see the numbers.\n`
|
||
);
|
||
return 1;
|
||
}
|
||
const plan = planAttach(row.session.muxName, {
|
||
socket: resolveTmuxSocketName(),
|
||
...(env.TMUX ? { tmux: env.TMUX } : {}),
|
||
});
|
||
if (plan.kind === 'refuse') {
|
||
process.stderr.write(`${palette.warn(plan.message)}\n`);
|
||
return 1;
|
||
}
|
||
if (plan.kind === 'attach') stdout.write(`${palette.muted(plan.hint)}\n`);
|
||
const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' });
|
||
if (result.error) {
|
||
process.stderr.write(`${palette.err(getErrorMessage(result.error))}\n`);
|
||
return 1;
|
||
}
|
||
return result.status ?? 0;
|
||
} finally {
|
||
client.close();
|
||
}
|
||
}
|