codemax-cli 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +27 -0
- package/README.md +163 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +78 -0
- package/package.json +72 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1934 -0
- package/src/chat/logo.ts +37 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +342 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +114 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +215 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/review.ts +67 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/update.ts +90 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/src/chat/logo.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The codemax wordmark for the terminal: the same halftone grid the website
|
|
3
|
+
* uses ("codemax" in Inter Tight 800, 13 rows, 0 = empty … 9 = full), drawn
|
|
4
|
+
* with half blocks so two grid rows fit in one terminal line.
|
|
5
|
+
*/
|
|
6
|
+
const GRID = [
|
|
7
|
+
"0000000000000000000000000028870000000000000000000000000000000000000000000000",
|
|
8
|
+
"0000000000000000000000000029970000000000000000000000000000000000000000000000",
|
|
9
|
+
"0000000000000000000000000029970000000000000000000000000000000000000000000000",
|
|
10
|
+
"0036876200003687510000477429970004787410057715773027861000267863004773005773",
|
|
11
|
+
"0699999930069999993006999979970079999981079969999499998104999999501998029970",
|
|
12
|
+
"3999559980499856998039996599970599635996079996899976999319983399910599369920",
|
|
13
|
+
"7994006992899300799359960049970899100799079930299800699402210189920189899600",
|
|
14
|
+
"8992000000999100599569940029971999999999179920199700599401578999920049999100",
|
|
15
|
+
"8992000000999100599569940029971999777777179920199700599418998689920049999100",
|
|
16
|
+
"7994006992899300799359960049970999000121079920199700599449950079920199899600",
|
|
17
|
+
"3998459980599846999139996599970599634998079920199700599459951399920699369930",
|
|
18
|
+
"0699999930079999993007999979970189999993079920199700599429999989922997029980",
|
|
19
|
+
"0047886200004788620000577517750015788620057720177500477303787347725772004773",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
/** Logo lines for a terminal this wide: the full 76-column logo from 78 columns, none below. */
|
|
23
|
+
export function logoLines(width: number): string[] {
|
|
24
|
+
// Squeezed to half width the letters run together, so narrow terminals get the text title instead.
|
|
25
|
+
if (width < 78) return [];
|
|
26
|
+
const on = (r: number, c: number) => Number(GRID[r]?.[c] ?? "0") >= 4;
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
for (let r = 0; r < GRID.length; r += 2) {
|
|
29
|
+
let line = "";
|
|
30
|
+
for (let c = 0; c < GRID[0]!.length; c++) {
|
|
31
|
+
const up = on(r, c), down = on(r + 1, c);
|
|
32
|
+
line += up && down ? "█" : up ? "▀" : down ? "▄" : " ";
|
|
33
|
+
}
|
|
34
|
+
lines.push(line.replace(/\s+$/, ""));
|
|
35
|
+
}
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { StyledText, bold, dim, fg, italic, stringToStyledText, type TextChunk } from "@opentui/core";
|
|
2
|
+
import { C } from "./theme.ts";
|
|
3
|
+
|
|
4
|
+
const plain = (s: string): TextChunk[] => (s ? stringToStyledText(s).chunks : []);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal markdown styling for streamed agent text, done without a parser so it
|
|
8
|
+
* is cheap to re-run on every append: headings bold, `code` dimmed with an
|
|
9
|
+
* accent, **bold**, *italic*, bullets normalised, fenced code blocks indented
|
|
10
|
+
* and dimmed, and unified-diff lines coloured.
|
|
11
|
+
*/
|
|
12
|
+
export function styleMarkdown(text: string): StyledText {
|
|
13
|
+
const chunks: TextChunk[] = [];
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
let inFence = false;
|
|
16
|
+
lines.forEach((line, i) => {
|
|
17
|
+
if (/^\s*```/.test(line)) {
|
|
18
|
+
inFence = !inFence;
|
|
19
|
+
const lang = line.replace(/^\s*```/, "").trim();
|
|
20
|
+
chunks.push(dim(lang ? `── ${lang} ──` : "──"));
|
|
21
|
+
} else if (inFence) {
|
|
22
|
+
chunks.push(...diffOrCode(line));
|
|
23
|
+
} else if (/^#{1,6}\s/.test(line)) {
|
|
24
|
+
chunks.push(bold(line.replace(/^#{1,6}\s+/, "")));
|
|
25
|
+
} else if (/^\s*[-*•]\s+/.test(line)) {
|
|
26
|
+
const m = line.match(/^(\s*)[-*•]\s+(.*)$/)!;
|
|
27
|
+
chunks.push(plain(`${m[1]}• `)[0]!, ...inline(m[2]!));
|
|
28
|
+
} else if (/^\s*\d+\.\s+/.test(line)) {
|
|
29
|
+
const m = line.match(/^(\s*\d+\.\s+)(.*)$/)!;
|
|
30
|
+
chunks.push(dim(m[1]!), ...inline(m[2]!));
|
|
31
|
+
} else if (/^\s*>\s?/.test(line)) {
|
|
32
|
+
chunks.push(dim("│ "), ...inline(line.replace(/^\s*>\s?/, "")).map((c) => c));
|
|
33
|
+
} else {
|
|
34
|
+
chunks.push(...inline(line));
|
|
35
|
+
}
|
|
36
|
+
if (i < lines.length - 1) chunks.push(...plain("\n"));
|
|
37
|
+
});
|
|
38
|
+
return new StyledText(chunks.length ? chunks : plain(""));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function diffOrCode(line: string): TextChunk[] {
|
|
42
|
+
if (/^\+(?!\+\+)/.test(line)) return [fg(C.diffAdd)(` ${line}`)];
|
|
43
|
+
if (/^-(?!--)/.test(line)) return [fg(C.diffDel)(` ${line}`)];
|
|
44
|
+
if (/^@@/.test(line)) return [fg(C.accent)(` ${line}`)];
|
|
45
|
+
return [dim(` ${line}`)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Inline: `code`, **bold**, *italic*. */
|
|
49
|
+
function inline(s: string): TextChunk[] {
|
|
50
|
+
const out: TextChunk[] = [];
|
|
51
|
+
const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\s][^*]*\*)/g;
|
|
52
|
+
let last = 0;
|
|
53
|
+
let m: RegExpExecArray | null;
|
|
54
|
+
while ((m = re.exec(s))) {
|
|
55
|
+
if (m.index > last) out.push(...plain(s.slice(last, m.index)));
|
|
56
|
+
if (m[1]) out.push(fg(C.accent)(m[1].slice(1, -1)));
|
|
57
|
+
else if (m[2]) out.push(bold(m[2].slice(2, -2)));
|
|
58
|
+
else if (m[3]) out.push(italic(m[3].slice(1, -1)));
|
|
59
|
+
last = m.index + m[0].length;
|
|
60
|
+
}
|
|
61
|
+
if (last < s.length) out.push(...plain(s.slice(last)));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Tool output: colour diff lines, dim the rest. */
|
|
66
|
+
export function styleToolOutput(text: string): StyledText {
|
|
67
|
+
const chunks: TextChunk[] = [];
|
|
68
|
+
const lines = text.split("\n");
|
|
69
|
+
lines.forEach((line, i) => {
|
|
70
|
+
if (/^\+(?!\+\+)/.test(line)) chunks.push(fg(C.diffAdd)(line));
|
|
71
|
+
else if (/^-(?!--)/.test(line)) chunks.push(fg(C.diffDel)(line));
|
|
72
|
+
else if (/^@@/.test(line)) chunks.push(fg(C.accent)(line));
|
|
73
|
+
else chunks.push(dim(line));
|
|
74
|
+
if (i < lines.length - 1) chunks.push(...plain("\n"));
|
|
75
|
+
});
|
|
76
|
+
return new StyledText(chunks.length ? chunks : plain(""));
|
|
77
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
import { C, G } from "./theme.ts";
|
|
3
|
+
|
|
4
|
+
export interface PaletteItem {
|
|
5
|
+
/** What is matched against the filter and shown first (e.g. "/switch" or "claude"). */
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
/** Optional right-aligned hint (session age, quota, version). */
|
|
9
|
+
hint?: string;
|
|
10
|
+
value: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Tie-breaker among equal matches; higher sorts first (built-ins over agent commands). */
|
|
13
|
+
weight?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PaletteOptions {
|
|
17
|
+
items: PaletteItem[];
|
|
18
|
+
/** Initial filter text (e.g. "sw" when the user already typed "/sw"). */
|
|
19
|
+
filter?: string;
|
|
20
|
+
/** Header line shown above the rows (e.g. "Switch agent"). */
|
|
21
|
+
title?: string;
|
|
22
|
+
onPick: (item: PaletteItem) => void;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
/** Rows to show at most. */
|
|
25
|
+
maxRows?: number;
|
|
26
|
+
/** Tab picks the highlighted item instead of moving down (file completion). */
|
|
27
|
+
tabAccepts?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one list UI codemax uses everywhere: slash commands, /switch, /mode.
|
|
32
|
+
* Anchored above the input like Claude Code's command menu: `❯ /name description`.
|
|
33
|
+
* Filtering is prefix-or-word based so typos never auto-run anything.
|
|
34
|
+
*/
|
|
35
|
+
export class Palette {
|
|
36
|
+
readonly box: BoxRenderable;
|
|
37
|
+
private rows: TextRenderable[] = [];
|
|
38
|
+
private titleRow: TextRenderable;
|
|
39
|
+
private cursor = 0;
|
|
40
|
+
private filter: string;
|
|
41
|
+
private matches: PaletteItem[] = [];
|
|
42
|
+
private maxRows: number;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private renderer: CliRenderer,
|
|
46
|
+
private opts: PaletteOptions,
|
|
47
|
+
private anchor: () => { top: number; width: number },
|
|
48
|
+
) {
|
|
49
|
+
this.filter = opts.filter ?? "";
|
|
50
|
+
this.maxRows = opts.maxRows ?? 8;
|
|
51
|
+
this.box = new BoxRenderable(renderer, { id: `palette-${Date.now()}`, position: "absolute", left: 0, top: 0, width: "100%", zIndex: 800, flexDirection: "column", paddingX: 1, backgroundColor: C.panel });
|
|
52
|
+
this.titleRow = new TextRenderable(renderer, { content: "", fg: C.panelDim, bg: C.panel, wrapMode: "none", truncate: true, width: "100%" });
|
|
53
|
+
this.box.add(this.titleRow);
|
|
54
|
+
for (let i = 0; i < this.maxRows; i++) {
|
|
55
|
+
const t = new TextRenderable(renderer, { content: "", fg: C.panelText, bg: C.panel, wrapMode: "none", truncate: true, width: "100%" });
|
|
56
|
+
this.rows.push(t);
|
|
57
|
+
this.box.add(t);
|
|
58
|
+
}
|
|
59
|
+
this.setFilter(this.filter);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setFilter(text: string) {
|
|
63
|
+
this.filter = text;
|
|
64
|
+
const q = text.trim().toLowerCase();
|
|
65
|
+
const scored = this.opts.items
|
|
66
|
+
.map((it) => ({ it, s: score(it, q) }))
|
|
67
|
+
.filter((x) => x.s > 0)
|
|
68
|
+
.sort((a, b) => b.s - a.s || (b.it.weight ?? 0) - (a.it.weight ?? 0) || a.it.label.localeCompare(b.it.label));
|
|
69
|
+
this.matches = scored.map((x) => x.it);
|
|
70
|
+
this.cursor = Math.min(this.cursor, Math.max(0, this.matches.length - 1));
|
|
71
|
+
|
|
72
|
+
this.render();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Replace items without losing the highlighted row (matched by value). */
|
|
76
|
+
setItems(items: PaletteItem[]) {
|
|
77
|
+
const keep = this.current?.value;
|
|
78
|
+
this.opts.items = items;
|
|
79
|
+
this.setFilter(this.filter);
|
|
80
|
+
const i = this.matches.findIndex((m) => m.value === keep);
|
|
81
|
+
if (i >= 0) {
|
|
82
|
+
this.cursor = i;
|
|
83
|
+
this.render();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get current(): PaletteItem | undefined {
|
|
88
|
+
return this.matches[this.cursor];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render() {
|
|
92
|
+
const { top, width } = this.anchor();
|
|
93
|
+
const visible = this.matches.slice(0, this.maxRows);
|
|
94
|
+
const shown = visible.length;
|
|
95
|
+
const titleLines = this.opts.title ? 1 : 0;
|
|
96
|
+
this.titleRow.visible = !!this.opts.title;
|
|
97
|
+
this.titleRow.content = this.opts.title ?? "";
|
|
98
|
+
this.box.height = shown + titleLines + (shown === 0 ? 1 : 0);
|
|
99
|
+
this.box.top = Math.max(0, top - this.box.height);
|
|
100
|
+
const labelW = Math.min(Math.max(28, Math.floor(width * 0.35)), Math.max(10, ...visible.map((v) => v.label.length + 2)));
|
|
101
|
+
this.rows.forEach((row, i) => {
|
|
102
|
+
const it = visible[i];
|
|
103
|
+
if (!it) {
|
|
104
|
+
if (i === 0 && shown === 0) {
|
|
105
|
+
row.visible = true;
|
|
106
|
+
row.content = this.filter ? ` no matches for "${this.filter}"` : " nothing here";
|
|
107
|
+
row.fg = C.panelDim;
|
|
108
|
+
row.bg = C.panel;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
row.visible = false;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
row.visible = true;
|
|
115
|
+
const sel = i === this.cursor;
|
|
116
|
+
const label = pad(it.label, labelW);
|
|
117
|
+
const desc = it.description ?? "";
|
|
118
|
+
const hint = it.hint ? ` ${it.hint}` : "";
|
|
119
|
+
const bodyW = Math.max(10, width - 2 - 2 - labelW - hint.length);
|
|
120
|
+
row.content = `${sel ? G.prompt : " "} ${label}${desc.slice(0, bodyW).padEnd(bodyW)}${hint}`;
|
|
121
|
+
row.fg = it.disabled ? C.panelDim : sel ? C.selFg : C.panelText;
|
|
122
|
+
row.bg = sel ? C.selBg : C.panel;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Returns true when the key was consumed. */
|
|
127
|
+
handleKey(key: KeyEvent): boolean {
|
|
128
|
+
const n = key.name;
|
|
129
|
+
if (n === "up" || (n === "p" && key.ctrl)) return this.move(-1);
|
|
130
|
+
if (n === "down" || (n === "n" && key.ctrl)) return this.move(1);
|
|
131
|
+
if (n === "tab" && !key.shift) {
|
|
132
|
+
if (this.opts.tabAccepts) {
|
|
133
|
+
const cur = this.current;
|
|
134
|
+
if (cur && !cur.disabled) this.opts.onPick(cur);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return this.move(1);
|
|
138
|
+
}
|
|
139
|
+
if (n === "escape") {
|
|
140
|
+
this.opts.onClose();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (n === "return") {
|
|
144
|
+
const cur = this.current;
|
|
145
|
+
if (cur && !cur.disabled) this.opts.onPick(cur);
|
|
146
|
+
else if (!cur) this.opts.onClose();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private move(d: number): boolean {
|
|
153
|
+
const n = Math.min(this.matches.length, this.maxRows);
|
|
154
|
+
if (!n) return true;
|
|
155
|
+
this.cursor = (this.cursor + d + n) % n;
|
|
156
|
+
this.render();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
destroy() {
|
|
161
|
+
this.box.destroyRecursively();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 3 = prefix match, 2 = word-start match (after : _ - /), 1 = substring in description; 0 = no match. */
|
|
166
|
+
function score(it: PaletteItem, q: string): number {
|
|
167
|
+
if (!q) return 1;
|
|
168
|
+
const label = it.label.toLowerCase().replace(/^\//, "");
|
|
169
|
+
const needle = q.replace(/^\//, "");
|
|
170
|
+
if (label.startsWith(needle)) return 3;
|
|
171
|
+
if (label.split(/[:_\-/. ]/).some((w) => w.startsWith(needle))) return 2;
|
|
172
|
+
if (label.includes(needle)) return 1.5;
|
|
173
|
+
if ((it.description ?? "").toLowerCase().includes(needle)) return 1;
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function pad(s: string, n: number): string {
|
|
178
|
+
return s.length >= n ? s.slice(0, n - 1) + " " : s + " ".repeat(n - s.length);
|
|
179
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
import type { PermissionRequest } from "../engines/types.ts";
|
|
3
|
+
import { C, G } from "./theme.ts";
|
|
4
|
+
|
|
5
|
+
export type PermissionChoice = { optionId: string | null; tellAgent?: boolean };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Permission dialog in Claude Code's words:
|
|
9
|
+
* 1. Yes
|
|
10
|
+
* 2. Yes, and don't ask again for this session (when offered)
|
|
11
|
+
* 3. No, and tell <agent> what to do differently
|
|
12
|
+
* Esc = No. Option 3 denies and hands focus back to the input.
|
|
13
|
+
*/
|
|
14
|
+
export class PermissionDialog {
|
|
15
|
+
readonly box: BoxRenderable;
|
|
16
|
+
private cursor = 0;
|
|
17
|
+
private rows: TextRenderable[] = [];
|
|
18
|
+
private choices: { label: string; optionId: string | null; tellAgent?: boolean }[];
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
renderer: CliRenderer,
|
|
22
|
+
readonly request: PermissionRequest,
|
|
23
|
+
agentName: string,
|
|
24
|
+
private onChoice: (c: PermissionChoice) => void,
|
|
25
|
+
) {
|
|
26
|
+
const once = request.options.find((o) => o.kind === "allow_once") ?? request.options.find((o) => o.kind.startsWith("allow"));
|
|
27
|
+
const always = request.options.find((o) => o.kind === "allow_always");
|
|
28
|
+
const reject = request.options.find((o) => o.kind === "reject_once") ?? request.options.find((o) => o.kind.startsWith("reject"));
|
|
29
|
+
this.choices = [
|
|
30
|
+
{ label: "Yes", optionId: once?.id ?? request.options[0]!.id },
|
|
31
|
+
...(always ? [{ label: "Yes, and don't ask again for this session", optionId: always.id }] : []),
|
|
32
|
+
{ label: `No, and tell ${agentName} what to do differently`, optionId: reject?.id ?? null, tellAgent: true },
|
|
33
|
+
];
|
|
34
|
+
this.box = new BoxRenderable(renderer, {
|
|
35
|
+
id: `perm-${request.id}`,
|
|
36
|
+
width: "100%",
|
|
37
|
+
flexShrink: 0,
|
|
38
|
+
border: true,
|
|
39
|
+
borderStyle: "rounded",
|
|
40
|
+
borderColor: C.accent,
|
|
41
|
+
paddingX: 1,
|
|
42
|
+
flexDirection: "column",
|
|
43
|
+
});
|
|
44
|
+
const what = request.title.length > 160 ? request.title.slice(0, 157) + "…" : request.title;
|
|
45
|
+
this.box.add(new TextRenderable(renderer, { content: `${agentName} wants to ${verb(request.kind, what)}`, fg: C.accent }));
|
|
46
|
+
this.box.add(new TextRenderable(renderer, { content: what, wrapMode: "word", width: "100%" }));
|
|
47
|
+
const detail = request.detail && !request.title.includes(request.detail.split("\n")[0]!.replace(/^\w+: /, "")) ? request.detail : undefined;
|
|
48
|
+
if (detail) this.box.add(new TextRenderable(renderer, { content: detail.slice(0, 500), fg: C.dim, wrapMode: "word", width: "100%" }));
|
|
49
|
+
this.box.add(new TextRenderable(renderer, { content: "" }));
|
|
50
|
+
this.box.add(new TextRenderable(renderer, { content: "Do you want to proceed?" }));
|
|
51
|
+
this.rows = this.choices.map(() => {
|
|
52
|
+
const t = new TextRenderable(renderer, { content: "", wrapMode: "none", truncate: true, width: "100%" });
|
|
53
|
+
this.box.add(t);
|
|
54
|
+
return t;
|
|
55
|
+
});
|
|
56
|
+
this.box.add(new TextRenderable(renderer, { content: "1-3 or ↑/↓ then Enter · Esc to cancel", fg: C.dim }));
|
|
57
|
+
this.render();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private render() {
|
|
61
|
+
this.choices.forEach((c, i) => {
|
|
62
|
+
const sel = i === this.cursor;
|
|
63
|
+
this.rows[i]!.content = ` ${sel ? G.prompt : " "} ${i + 1}. ${c.label}`;
|
|
64
|
+
this.rows[i]!.fg = sel ? C.accent : undefined;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
handleKey(key: KeyEvent): boolean {
|
|
69
|
+
const n = key.name;
|
|
70
|
+
const len = this.choices.length;
|
|
71
|
+
if (n === "up") this.cursor = (this.cursor - 1 + len) % len;
|
|
72
|
+
else if (n === "down" || n === "tab") this.cursor = (this.cursor + 1) % len;
|
|
73
|
+
else if (n === "return") return this.pick(this.cursor);
|
|
74
|
+
else if (/^[1-9]$/.test(n) && Number(n) <= len) return this.pick(Number(n) - 1);
|
|
75
|
+
// No letter shortcuts: text typed while the dialog is open must never pick an option.
|
|
76
|
+
else if (n === "escape") return this.pick(len - 1);
|
|
77
|
+
this.render();
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private pick(i: number): boolean {
|
|
82
|
+
const c = this.choices[i]!;
|
|
83
|
+
this.onChoice({ optionId: c.optionId, tellAgent: c.tellAgent });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
destroy() {
|
|
88
|
+
this.box.destroyRecursively();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function verb(kind: string | undefined, title: string): string {
|
|
93
|
+
switch (kind) {
|
|
94
|
+
case "execute":
|
|
95
|
+
return "run a command";
|
|
96
|
+
case "edit":
|
|
97
|
+
case "delete":
|
|
98
|
+
case "move":
|
|
99
|
+
return "edit files";
|
|
100
|
+
case "fetch":
|
|
101
|
+
return "access the web";
|
|
102
|
+
case "read":
|
|
103
|
+
case "search":
|
|
104
|
+
return "read files";
|
|
105
|
+
}
|
|
106
|
+
const t = title.toLowerCase();
|
|
107
|
+
if (/^(write|edit|create|update|delete|edit files)/.test(t)) return "edit files";
|
|
108
|
+
if (/^(`|bash|run|terminal|\$|command|sh\b)/.test(t)) return "run a command";
|
|
109
|
+
if (/fetch|http|web/.test(t)) return "access the web";
|
|
110
|
+
return "use a tool";
|
|
111
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glyphs and colours. The codemax brand theme: pink is the accent and nothing
|
|
3
|
+
* else, plum is the one dark ink, and warnings, errors and success keep their
|
|
4
|
+
* own hues so they never blur into the brand colour.
|
|
5
|
+
*
|
|
6
|
+
* Two palettes, picked at startup from the terminal's background (OSC 11, via
|
|
7
|
+
* OpenTUI) or the `theme` config: "auto" | "dark" | "light". Every coloured
|
|
8
|
+
* surface sets its own text colour too, so nothing relies on the terminal's
|
|
9
|
+
* default foreground against a painted background.
|
|
10
|
+
*/
|
|
11
|
+
export const G = {
|
|
12
|
+
prompt: "❯",
|
|
13
|
+
bullet: "⏺",
|
|
14
|
+
nest: "⎿",
|
|
15
|
+
spinner: ["✻", "✶", "✳", "✢", "·", "✽"],
|
|
16
|
+
done: "✻",
|
|
17
|
+
thought: "∴",
|
|
18
|
+
todo: "☐",
|
|
19
|
+
doing: "◼",
|
|
20
|
+
done_: "☒",
|
|
21
|
+
modeOn: "⏵⏵",
|
|
22
|
+
modeAsk: "⏵",
|
|
23
|
+
modePause: "⏸",
|
|
24
|
+
rule: "─",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type ThemeName = "dark" | "light";
|
|
28
|
+
|
|
29
|
+
export interface Palette {
|
|
30
|
+
/** Brand pink: prompt glyph, borders, selected rows, links, inline code. */
|
|
31
|
+
accent: string;
|
|
32
|
+
/** Assistant `⏺` bullet. */
|
|
33
|
+
assistant: string;
|
|
34
|
+
/** Tool-call `⏺` bullet. */
|
|
35
|
+
tool: string;
|
|
36
|
+
ok: string;
|
|
37
|
+
warn: string;
|
|
38
|
+
error: string;
|
|
39
|
+
/** Plan mode indicator. */
|
|
40
|
+
plan: string;
|
|
41
|
+
dim: string;
|
|
42
|
+
/** Chrome: `⎿`, rules, disabled rows. */
|
|
43
|
+
dimmer: string;
|
|
44
|
+
/** Overlay (palette) background and its text. */
|
|
45
|
+
panel: string;
|
|
46
|
+
panelText: string;
|
|
47
|
+
panelDim: string;
|
|
48
|
+
/** Selected row inside an overlay. */
|
|
49
|
+
selBg: string;
|
|
50
|
+
selFg: string;
|
|
51
|
+
diffAdd: string;
|
|
52
|
+
diffDel: string;
|
|
53
|
+
/** Reasoning text. */
|
|
54
|
+
thought: string;
|
|
55
|
+
/** Terminal default foreground. */
|
|
56
|
+
text: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DARK: Palette = {
|
|
60
|
+
accent: "#ff5c8d",
|
|
61
|
+
assistant: "#ffc3d6",
|
|
62
|
+
tool: "#ff4f86",
|
|
63
|
+
ok: "#4ade80",
|
|
64
|
+
warn: "#fbbf24",
|
|
65
|
+
error: "#f87171",
|
|
66
|
+
plan: "#67e8f9",
|
|
67
|
+
dim: "#9ca3af",
|
|
68
|
+
dimmer: "#4b5563",
|
|
69
|
+
panel: "#2a0a1b",
|
|
70
|
+
panelText: "#ffe4ee",
|
|
71
|
+
panelDim: "#b88a9f",
|
|
72
|
+
selBg: "#ff5c8d",
|
|
73
|
+
selFg: "#3a0d25",
|
|
74
|
+
diffAdd: "#4ade80",
|
|
75
|
+
diffDel: "#f87171",
|
|
76
|
+
thought: "#b88a9f",
|
|
77
|
+
text: undefined,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const LIGHT: Palette = {
|
|
81
|
+
accent: "#e23571",
|
|
82
|
+
assistant: "#3a0d25",
|
|
83
|
+
tool: "#ec3570",
|
|
84
|
+
ok: "#15803d",
|
|
85
|
+
warn: "#b45309",
|
|
86
|
+
error: "#c81e1e",
|
|
87
|
+
plan: "#0e7490",
|
|
88
|
+
dim: "#6b7280",
|
|
89
|
+
dimmer: "#c9bcc3",
|
|
90
|
+
panel: "#fff0f5",
|
|
91
|
+
panelText: "#3a0d25",
|
|
92
|
+
panelDim: "#8a6a7a",
|
|
93
|
+
selBg: "#ffc3d6",
|
|
94
|
+
selFg: "#3a0d25",
|
|
95
|
+
diffAdd: "#15803d",
|
|
96
|
+
diffDel: "#c81e1e",
|
|
97
|
+
thought: "#8a6a7a",
|
|
98
|
+
text: undefined,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The live palette. Read at render time; `applyTheme` swaps it in place. */
|
|
102
|
+
export const C: Palette = { ...DARK };
|
|
103
|
+
export let THEME: ThemeName = "dark";
|
|
104
|
+
|
|
105
|
+
export function applyTheme(mode: ThemeName) {
|
|
106
|
+
Object.assign(C, mode === "light" ? LIGHT : DARK);
|
|
107
|
+
THEME = mode;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const VERBS = ["Thinking", "Pondering", "Brewing", "Weaving", "Crunching", "Sketching", "Tinkering", "Composing", "Mulling", "Noodling", "Percolating", "Assembling"];
|
|
111
|
+
export const DONE_VERBS = ["Worked", "Cooked", "Crunched", "Pondered", "Tinkered", "Composed"];
|
|
112
|
+
export const TIPS = [
|
|
113
|
+
"Ctrl+^ switches agent and hands over the conversation",
|
|
114
|
+
"Ctrl+O expands tool output and thinking",
|
|
115
|
+
"/ask codex <msg> asks another agent without switching",
|
|
116
|
+
"/undo reverts the files the last turn changed",
|
|
117
|
+
"@file attaches a file's contents to your message",
|
|
118
|
+
"!cmd runs a shell command and attaches its output",
|
|
119
|
+
"/usage shows what each plan has covered today",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export function pick<T>(arr: readonly T[], seed = Date.now()): T {
|
|
123
|
+
return arr[seed % arr.length]!;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Footer wording per permission mode, matching Claude Code's indicator style. */
|
|
127
|
+
export function modeLabel(agent: string, modeId: string | undefined): { glyph: string; text: string; color: string } {
|
|
128
|
+
switch (modeId) {
|
|
129
|
+
case "acceptEdits":
|
|
130
|
+
return { glyph: G.modeOn, text: "accept edits on", color: C.accent };
|
|
131
|
+
case "plan":
|
|
132
|
+
return { glyph: G.modePause, text: "plan mode on", color: C.plan };
|
|
133
|
+
case "bypassPermissions":
|
|
134
|
+
case "never":
|
|
135
|
+
return { glyph: G.modeOn, text: "bypass permissions on", color: C.error };
|
|
136
|
+
case "dontAsk":
|
|
137
|
+
return { glyph: G.modeOn, text: "don't ask on", color: C.error };
|
|
138
|
+
case "auto":
|
|
139
|
+
return { glyph: G.modeOn, text: "auto mode on", color: C.warn };
|
|
140
|
+
case "ask":
|
|
141
|
+
return { glyph: G.modePause, text: "ask mode · no edits", color: C.plan };
|
|
142
|
+
case "on-request":
|
|
143
|
+
case "default":
|
|
144
|
+
default:
|
|
145
|
+
return { glyph: G.modeAsk, text: "asks before acting", color: C.dim };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function fmtDuration(ms: number): string {
|
|
150
|
+
const s = Math.round(ms / 1000);
|
|
151
|
+
if (s < 60) return `${s}s`;
|
|
152
|
+
const m = Math.floor(s / 60);
|
|
153
|
+
return `${m}m ${s - m * 60}s`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function fmtTime(ts: number): string {
|
|
157
|
+
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
158
|
+
}
|