@wizardingcode/shibaox-tui 0.1.0

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.
Files changed (61) hide show
  1. package/bunfig.toml +5 -0
  2. package/package.json +42 -0
  3. package/src/app.tsx +300 -0
  4. package/src/component/dialogs/confirm.tsx +29 -0
  5. package/src/component/dialogs/diff.tsx +143 -0
  6. package/src/component/dialogs/help.tsx +99 -0
  7. package/src/component/dialogs/keys.tsx +201 -0
  8. package/src/component/dialogs/palette.tsx +95 -0
  9. package/src/component/dialogs/runs.tsx +115 -0
  10. package/src/component/dialogs/tiers.tsx +240 -0
  11. package/src/component/footer.tsx +66 -0
  12. package/src/component/logo.tsx +29 -0
  13. package/src/component/prompt/autocomplete.tsx +44 -0
  14. package/src/component/prompt/index.tsx +213 -0
  15. package/src/component/reconnecting.tsx +44 -0
  16. package/src/component/sidebar.tsx +235 -0
  17. package/src/component/tabs.tsx +116 -0
  18. package/src/component/too-small.tsx +15 -0
  19. package/src/context/client.tsx +54 -0
  20. package/src/context/commands.tsx +44 -0
  21. package/src/context/config.tsx +22 -0
  22. package/src/context/data-state.ts +41 -0
  23. package/src/context/data.tsx +308 -0
  24. package/src/context/exit.tsx +13 -0
  25. package/src/context/keys.tsx +70 -0
  26. package/src/context/poller.ts +352 -0
  27. package/src/context/prefs.tsx +54 -0
  28. package/src/context/route.tsx +23 -0
  29. package/src/main.tsx +55 -0
  30. package/src/model/format.ts +38 -0
  31. package/src/model/prompt-commands.ts +315 -0
  32. package/src/model/status.ts +55 -0
  33. package/src/model/stream.ts +464 -0
  34. package/src/motion/animation.ts +235 -0
  35. package/src/motion/config.tsx +15 -0
  36. package/src/motion/fade-in-text.tsx +93 -0
  37. package/src/motion/masked-text.ts +62 -0
  38. package/src/motion/one-cell-motion.ts +100 -0
  39. package/src/motion/pulse.ts +38 -0
  40. package/src/motion/shimmer-text.tsx +78 -0
  41. package/src/motion/spinner.tsx +101 -0
  42. package/src/motion/subcell.ts +40 -0
  43. package/src/routes/home.tsx +365 -0
  44. package/src/routes/session/approval-bar.tsx +136 -0
  45. package/src/routes/session/cards.tsx +495 -0
  46. package/src/routes/session/context-line.tsx +129 -0
  47. package/src/routes/session/continue.tsx +104 -0
  48. package/src/routes/session/index.tsx +340 -0
  49. package/src/routes/session/request.tsx +87 -0
  50. package/src/routes/session/timeline.tsx +313 -0
  51. package/src/testing/fake-client.ts +249 -0
  52. package/src/theme/context.tsx +44 -0
  53. package/src/theme/resolve.ts +196 -0
  54. package/src/theme/shibaox.json +159 -0
  55. package/src/ui/border.ts +20 -0
  56. package/src/ui/delayed-presence.ts +23 -0
  57. package/src/ui/dialog.tsx +123 -0
  58. package/src/ui/layout.ts +23 -0
  59. package/src/ui/marquee.ts +75 -0
  60. package/src/ui/pane-resize.ts +77 -0
  61. package/src/ui/toast.tsx +203 -0
@@ -0,0 +1,315 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import type { ModelChoice, SubmitRequest } from '@wizardingcode/shibaox-daemon';
5
+ import fuzzysort from 'fuzzysort';
6
+
7
+ export type Adapter = 'mock' | 'claude-code' | 'direct';
8
+ export const ADAPTERS: Adapter[] = ['mock', 'claude-code', 'direct'];
9
+ const WORKSPACES = ['inplace', 'worktree'] as const;
10
+
11
+ export interface PromptContext {
12
+ org: string;
13
+ project: string;
14
+ workflow?: string;
15
+ adapter: Adapter;
16
+ budgetUsd?: number;
17
+ workspace?: 'inplace' | 'worktree';
18
+ /** A model ref (`provider/model`) for every task; the adapter follows from it. */
19
+ model?: string;
20
+ }
21
+
22
+ export const COMMANDS = [
23
+ 'model',
24
+ 'workflow',
25
+ 'project',
26
+ 'org',
27
+ 'adapter',
28
+ 'budget',
29
+ 'workspace',
30
+ 'runs',
31
+ 'keys',
32
+ 'key',
33
+ 'tiers',
34
+ 'help',
35
+ ] as const;
36
+ export type CommandName = (typeof COMMANDS)[number];
37
+
38
+ /** How each command takes its value: from a list, as free text, or none (it acts at once). */
39
+ export const COMMAND_KIND: Record<CommandName, 'choice' | 'text' | 'action'> = {
40
+ model: 'choice',
41
+ workflow: 'choice',
42
+ adapter: 'choice',
43
+ workspace: 'choice',
44
+ project: 'text',
45
+ org: 'text',
46
+ budget: 'text',
47
+ runs: 'action',
48
+ keys: 'action',
49
+ key: 'text',
50
+ tiers: 'action',
51
+ help: 'action',
52
+ };
53
+
54
+ /** What to ask for when a free-text command is entered without its value. */
55
+ export const COMMAND_HINT: Record<CommandName, string> = {
56
+ model: 'pick the provider/model for the run',
57
+ workflow: 'pick a workflow of the org',
58
+ adapter: 'pick an adapter: mock, claude-code or direct',
59
+ workspace: 'pick inplace or worktree',
60
+ project: 'type the project directory and press enter',
61
+ org: 'type the org directory and press enter',
62
+ budget: 'type the budget in USD and press enter',
63
+ runs: 'open a run',
64
+ keys: 'the key vault: which API keys and tokens are set',
65
+ key: 'type NAME VALUE (e.g. OPENROUTER_API_KEY sk-or-…) and press enter',
66
+ tiers: "the org's model tiers, judge and adapter",
67
+ help: 'show the keys',
68
+ };
69
+ export interface PromptCommand {
70
+ command: string;
71
+ arg: string;
72
+ }
73
+
74
+ /** A value a choice command offers, with what to say about it. */
75
+ export interface ValueChoice {
76
+ value: string;
77
+ hint?: string;
78
+ /** Listed after the usable values whatever the match (a model whose key is missing). */
79
+ disabled?: boolean;
80
+ }
81
+
82
+ /** A `/command` a prompt offers: its kind decides what enter does, `values` feed the list. */
83
+ export interface PromptCommandSpec {
84
+ name: string;
85
+ kind: 'choice' | 'text' | 'action';
86
+ hint: string;
87
+ values?: () => readonly (string | ValueChoice)[];
88
+ }
89
+
90
+ /** The `/model` value that goes back to the org's routing (per-role tiers). */
91
+ export const DEFAULT_MODEL = 'default';
92
+
93
+ /** Whether `ref` looks like `provider/model`. */
94
+ export const isModelRef = (ref: string): boolean => /^[a-z0-9][a-z0-9-]*\/\S+$/i.test(ref);
95
+
96
+ /** `1.25`, `10.00`, `0.004`: two decimals, more only when the price needs them, never "0.00" for a price. */
97
+ const usd = (v: number): string =>
98
+ v === 0 ? '0.00' : v >= 0.1 ? v.toFixed(2) : v.toPrecision(2).replace(/\.?0+$/, '');
99
+
100
+ /** `$0.30/$2.50 per M`, `free`, or nothing when the price is unknown. */
101
+ export function priceLabel(m: Pick<ModelChoice, 'pricing' | 'free'>): string | undefined {
102
+ if (m.pricing && (m.pricing.input_per_m > 0 || m.pricing.output_per_m > 0))
103
+ return `$${usd(m.pricing.input_per_m)}/$${usd(m.pricing.output_per_m)} per M`;
104
+ if (m.free || (m.pricing && m.pricing.input_per_m === 0 && m.pricing.output_per_m === 0))
105
+ return 'free';
106
+ return undefined;
107
+ }
108
+
109
+ /** `128k ctx`, `1.0M ctx`. */
110
+ export function windowLabel(tokens: number | undefined): string | undefined {
111
+ if (!tokens) return undefined;
112
+ return tokens >= 1_000_000
113
+ ? `${(tokens / 1_000_000).toFixed(1)}M ctx`
114
+ : `${Math.round(tokens / 1000)}k ctx`;
115
+ }
116
+
117
+ /** What a usable model's hint says: where it runs, what it costs, how much it holds. */
118
+ function modelHint(m: ModelChoice): string {
119
+ if (m.local && m.available === false) return 'local (server not reachable)';
120
+ if (!m.local && !m.configured) return `needs ${m.missing?.join(', ') || 'configuration'}`;
121
+ return [m.local ? 'local' : (m.runtime ?? 'direct'), priceLabel(m), windowLabel(m.contextWindow)]
122
+ .filter((p): p is string => !!p)
123
+ .join(' · ');
124
+ }
125
+
126
+ /** `/model` values: org routing first, then the daemon's models, usable ones ahead, the others say what they miss. */
127
+ export function modelValues(models: readonly ModelChoice[]): ValueChoice[] {
128
+ return [
129
+ { value: DEFAULT_MODEL, hint: 'org routing' },
130
+ ...[...models]
131
+ .sort((a, b) => Number(b.configured) - Number(a.configured))
132
+ .map((m) => ({
133
+ value: m.ref,
134
+ hint: modelHint(m),
135
+ disabled: !m.configured || m.available === false,
136
+ })),
137
+ ];
138
+ }
139
+
140
+ /** The model spec of a `/model` command for both prompts. */
141
+ export function modelCommand(models: () => readonly ModelChoice[]): PromptCommandSpec {
142
+ return {
143
+ name: 'model',
144
+ kind: 'choice',
145
+ hint: COMMAND_HINT.model,
146
+ values: () => modelValues(models()),
147
+ };
148
+ }
149
+
150
+ /** The home prompt's commands (the run context), with the org's workflows and the models as values. */
151
+ export function homeCommands(
152
+ workflows: readonly string[],
153
+ models: readonly ModelChoice[] = [],
154
+ ): PromptCommandSpec[] {
155
+ return COMMANDS.map((name) =>
156
+ name === 'model'
157
+ ? modelCommand(() => models)
158
+ : {
159
+ name,
160
+ kind: COMMAND_KIND[name],
161
+ hint: COMMAND_HINT[name],
162
+ values:
163
+ name === 'workflow'
164
+ ? () => workflows
165
+ : name === 'adapter'
166
+ ? () => ADAPTERS
167
+ : name === 'workspace'
168
+ ? () => WORKSPACES
169
+ : undefined,
170
+ },
171
+ );
172
+ }
173
+
174
+ /** The adapter a chosen model runs through, from the daemon's list (`claude-code` for a subscription). */
175
+ export function adapterForModel(ref: string, models: readonly ModelChoice[]): Adapter | undefined {
176
+ const m = models.find((x) => x.ref === ref);
177
+ if (!m) return undefined;
178
+ return m.runtime === 'claude-code' ? 'claude-code' : 'direct';
179
+ }
180
+
181
+ export function parsePromptCommand(
182
+ text: string,
183
+ commands: readonly PromptCommandSpec[],
184
+ ): PromptCommand | undefined {
185
+ const m = /^\/(\S+)\s*(.*)$/s.exec(text.trim());
186
+ if (!m) return undefined;
187
+ const command = commands.find((c) => c.name === m[1]);
188
+ if (!command) return undefined;
189
+ return { command: command.name, arg: (m[2] ?? '').trim() };
190
+ }
191
+
192
+ /** Rows the suggestion list shows at most. */
193
+ export const SUGGESTIONS = 10;
194
+
195
+ function rank(query: string, targets: readonly string[], limit = SUGGESTIONS): string[] {
196
+ if (!query) return targets.slice(0, limit);
197
+ return fuzzysort.go(query, targets, { limit }).map((r) => r.target);
198
+ }
199
+
200
+ export interface Suggestion {
201
+ label: string;
202
+ insert: string;
203
+ /** What the command does, shown beside it. */
204
+ hint?: string;
205
+ }
206
+
207
+ /** Suggestions for the text typed so far: command names, then the values of the current command. */
208
+ export function completeCommand(
209
+ text: string,
210
+ o: { commands: readonly PromptCommandSpec[] },
211
+ ): Suggestion[] {
212
+ if (!text.startsWith('/')) return [];
213
+ const m = /^\/(\S*)(\s+(.*))?$/s.exec(text);
214
+ if (!m) return [];
215
+ const name = m[1] ?? '';
216
+ const byName = new Map(o.commands.map((c) => [c.name, c]));
217
+ if (m[2] === undefined) {
218
+ return rank(name, [...byName.keys()]).map((c) => ({
219
+ label: `/${c}`,
220
+ insert: `/${c} `,
221
+ hint: byName.get(c)?.hint,
222
+ }));
223
+ }
224
+ const arg = (m[3] ?? '').trim();
225
+ const values = (byName.get(name)?.values?.() ?? []).map((v) =>
226
+ typeof v === 'string' ? { value: v } : v,
227
+ );
228
+ const byValue = new Map(values.map((v) => [v.value, v]));
229
+ // rank everything, then put the usable values first and only then cut the list: a usable
230
+ // match must never be pushed out by better-scoring values that cannot run
231
+ const ranked = rank(
232
+ arg,
233
+ values.map((v) => v.value),
234
+ Number.POSITIVE_INFINITY,
235
+ );
236
+ const usable = ranked.filter((v) => !byValue.get(v)?.disabled);
237
+ const rest = ranked.filter((v) => byValue.get(v)?.disabled);
238
+ return [...usable, ...rest].slice(0, SUGGESTIONS).map((v) => ({
239
+ label: v,
240
+ insert: `/${name} ${v}`,
241
+ hint: byValue.get(v)?.hint,
242
+ }));
243
+ }
244
+
245
+ export function expandHome(p: string): string {
246
+ if (p === '~') return homedir();
247
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
248
+ return p;
249
+ }
250
+
251
+ /** Applies a `/command arg` to the prompt context, or explains why it cannot. */
252
+ export function applyPromptCommand(
253
+ ctx: PromptContext,
254
+ cmd: PromptCommand,
255
+ o: { cwd: string },
256
+ ): PromptContext | { error: string } {
257
+ const dir = (p: string) => {
258
+ const e = expandHome(p);
259
+ return isAbsolute(e) ? e : resolve(o.cwd, e);
260
+ };
261
+ switch (cmd.command as CommandName) {
262
+ case 'model': {
263
+ if (cmd.arg === DEFAULT_MODEL) return { ...ctx, model: undefined };
264
+ if (!isModelRef(cmd.arg))
265
+ return { error: 'Model must look like provider/model (see /model for the list)' };
266
+ return { ...ctx, model: cmd.arg };
267
+ }
268
+ case 'workflow':
269
+ if (!cmd.arg) return { error: 'Workflow name is required' };
270
+ return { ...ctx, workflow: cmd.arg };
271
+ case 'project': {
272
+ if (!cmd.arg) return { error: 'Project directory is required' };
273
+ const p = dir(cmd.arg);
274
+ if (!existsSync(p)) return { error: `Project not found: ${p}` };
275
+ return { ...ctx, project: p };
276
+ }
277
+ case 'org': {
278
+ if (!cmd.arg) return { error: 'Org directory is required' };
279
+ const p = dir(cmd.arg);
280
+ if (!existsSync(p)) return { error: `Org not found: ${p}` };
281
+ return { ...ctx, org: p };
282
+ }
283
+ case 'adapter': {
284
+ const a = ADAPTERS.find((x) => x === cmd.arg);
285
+ if (!a) return { error: `Adapter must be one of ${ADAPTERS.join(', ')}` };
286
+ // an adapter chosen by hand replaces the model (which would otherwise decide it)
287
+ return { ...ctx, adapter: a, model: undefined };
288
+ }
289
+ case 'budget': {
290
+ const n = Number(cmd.arg);
291
+ if (!cmd.arg || !Number.isFinite(n) || n <= 0) return { error: 'Budget must be a number' };
292
+ return { ...ctx, budgetUsd: n };
293
+ }
294
+ case 'workspace': {
295
+ const w = WORKSPACES.find((x) => x === cmd.arg);
296
+ if (!w) return { error: `Workspace must be one of ${WORKSPACES.join(', ')}` };
297
+ return { ...ctx, workspace: w };
298
+ }
299
+ default:
300
+ return ctx;
301
+ }
302
+ }
303
+
304
+ export function toSubmitRequest(ctx: PromptContext, input: string): SubmitRequest {
305
+ return {
306
+ orgRoot: ctx.org,
307
+ project: ctx.project,
308
+ workflow: ctx.workflow ?? '',
309
+ input,
310
+ adapter: ctx.adapter,
311
+ workspace: ctx.workspace,
312
+ budgetUsd: ctx.budgetUsd,
313
+ ...(ctx.model ? { model: ctx.model } : {}),
314
+ };
315
+ }
@@ -0,0 +1,55 @@
1
+ import type { Feedback } from '../theme/resolve.js';
2
+
3
+ export type StatusWord =
4
+ | 'Working'
5
+ | 'Needs you'
6
+ | 'Queued'
7
+ | 'Done'
8
+ | 'Failed'
9
+ | 'Paused'
10
+ | 'Cancelled';
11
+
12
+ export interface StatusLook {
13
+ word: StatusWord;
14
+ symbol: string;
15
+ feedback: Feedback | 'muted';
16
+ }
17
+
18
+ /** A run status as a word plus a symbol (never colour alone). */
19
+ export function statusOf(run: { status: string }): StatusLook {
20
+ switch (run.status) {
21
+ case 'running':
22
+ return { word: 'Working', symbol: '●', feedback: 'running' };
23
+ case 'waiting_human':
24
+ case 'waiting_approval':
25
+ return { word: 'Needs you', symbol: '●', feedback: 'warning' };
26
+ case 'queued':
27
+ return { word: 'Queued', symbol: '○', feedback: 'muted' };
28
+ case 'completed':
29
+ return { word: 'Done', symbol: '✓', feedback: 'success' };
30
+ case 'failed':
31
+ return { word: 'Failed', symbol: '✗', feedback: 'error' };
32
+ case 'paused_budget':
33
+ return { word: 'Paused', symbol: '‖', feedback: 'warning' };
34
+ default:
35
+ return { word: 'Cancelled', symbol: '—', feedback: 'muted' };
36
+ }
37
+ }
38
+
39
+ /** A node status as a symbol plus feedback colour. */
40
+ export function nodeLook(status: string): { symbol: string; feedback: Feedback | 'muted' } {
41
+ switch (status) {
42
+ case 'running':
43
+ return { symbol: '●', feedback: 'running' };
44
+ case 'waiting':
45
+ return { symbol: '●', feedback: 'warning' };
46
+ case 'completed':
47
+ case 'passed':
48
+ return { symbol: '✓', feedback: 'success' };
49
+ case 'failed':
50
+ case 'gate_failed':
51
+ return { symbol: '✗', feedback: 'error' };
52
+ default:
53
+ return { symbol: '○', feedback: 'muted' };
54
+ }
55
+ }