@tabbio-technologies/cli 1.2.8 → 1.3.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.
package/README.md CHANGED
@@ -191,6 +191,26 @@ personal token, it only deletes the local copy: the token itself stays valid,
191
191
  because other clients may use it. Revoke it in Settings › MCP Access if you
192
192
  want it gone.
193
193
 
194
+ ### Inside the app: `/login` and `/logout`
195
+
196
+ `tabbio` opens the home screen even when you are not signed in: the account
197
+ line says **Not signed in**, the palette offers `/login`, `/help` and `/exit`,
198
+ and `/chat`, `/tools`, `/approvals` and `/status` answer "Sign in first:
199
+ /login" instead of failing. `/login` works on the home screen and in a chat,
200
+ without leaving the app:
201
+
202
+ | Command | Method |
203
+ |---|---|
204
+ | `/login` | A chooser: open the browser (recommended), email code, or personal token |
205
+ | `/login browser` | Opens the approval page and waits; the link is shown too, and Esc cancels |
206
+ | `/login email you@example.com` | Sends a 6-digit code and asks for it in place (three tries) |
207
+ | `/login token` | Asks for a personal MCP token (masked) and checks it against the tool list |
208
+ | `/logout` | Ends the session on the server, deletes this profile's credentials and returns to the signed-out home screen |
209
+
210
+ They use the same flows and the same credential store as `tabbio login` and
211
+ `tabbio logout`, and the home screen, the plan, the tool count and your skills
212
+ refresh for the new account right away.
213
+
194
214
  ## Commands
195
215
 
196
216
  ```
@@ -209,7 +229,7 @@ tabbio workflows says that workflows are not exposed ove
209
229
  tabbio approvals [list] [--json] ; approvals show <id> ; approvals approve <id> [--reason r] [--wait] ; approvals reject <id> [--reason r] ; approvals wait <id> [--timeout 10m]
210
230
  tabbio artifacts [list] [--kind k] [--status s] ; artifacts get|open|versions|delete|wait <id> ; artifacts download <id> [--format f] [--out p] [--force] [--wait] ; artifacts restore <id> <versionId>
211
231
  tabbio ask "<question>" [--mode m] [--via-mcp] [--json] one question through the chat stream (app session)
212
- tabbio chat [message] [--mode seeker|employer] [--company <id>] [--thread <id>] [--model <id>] [--research auto|on|off] [--image auto|on|off] [--result auto|summary|document|slides|page|image] [--save] [--json]
232
+ tabbio chat [message] [--mode seeker|employer] [--company <id>] [--thread <id>] [--model <id>] [--research auto|on|off] [--image auto|on|off] [--result auto|summary|document|slides|page|image] [--remember] [--no-save] [--json]
213
233
  tabbio mcp serve [--profile p] stdio ↔ remote MCP bridge for stdio-only clients
214
234
  tabbio mcp url prints the MCP URL for `claude mcp add`
215
235
  tabbio completion bash|zsh|fish
@@ -347,9 +367,14 @@ tabbio ask "Summarise my application pipeline" --json
347
367
 
348
368
  One question, one answer. With an app session (browser or email sign-in) the
349
369
  question goes through the chat stream (`POST /api/agent/chat`), exactly like a
350
- one-message `tabbio chat`: the answer streams, sources and files are listed,
351
- and it is metered like any chat reply. It sends the profile's saved research,
352
- image and result choice.
370
+ one-message `tabbio chat`: the answer streams, tool results print as the app's
371
+ cards (numbered jobs with their apply links, CVs with `/download <id>`,
372
+ skills as `/<skill-id>`), sources are listed, and it is metered like any chat
373
+ reply. It sends the profile's saved research, image and result choice.
374
+
375
+ Files the answer makes (pages, documents, slide decks, images) and CV exports
376
+ are saved to `./tabbio-artifacts` (see [Saved files](#saved-files)); `--no-save`
377
+ skips that.
353
378
 
354
379
  With a personal token, or with `--via-mcp`, it looks for the MCP ask tool
355
380
  (`ask_tabbio`) instead. Today's server does not offer that tool (the MCP
@@ -365,16 +390,43 @@ tabbio chat --mode employer --company cmp_456
365
390
  ```
366
391
 
367
392
  Streaming chat with the Tabbio agent, rendered as Markdown, with one-line
368
- tool-call rows (`ctrl+o` expands them) and inline approval prompts. Inside
369
- chat, `/new`, `/threads`, `/mode`, `/model`, `/research`, `/image`,
370
- `/result`, `/help` and `/exit` are available. With `--json`, chat prints the
371
- stream as NDJSON: one JSON event per line, exactly as the server sent it
372
- (including event types this version does not know). Needs a full sign-in.
393
+ tool-call rows (`ctrl+o` expands the raw result) and inline approval prompts.
394
+ A result the app shows as a card prints as one under its row: numbered rows,
395
+ dim details, links (clickable where the terminal supports it, and always
396
+ printed as plain URLs so they can be copied) and the next step, such as
397
+ `/open 2` or `/apply 2` for a job or `/download <id>` for a CV. Files Tabbio
398
+ makes and CV exports are saved to `./tabbio-artifacts` as soon as they are
399
+ ready, with the path, the size and the first lines of a page or document
400
+ (`--no-save` or `TABBIO_NO_ARTIFACT_SAVE=1` turn that off). Inside
401
+ chat, the slash commands below are available. With `--json`, chat prints
402
+ NDJSON: one server event per line (including unknown event types), followed
403
+ by a local `files-saved` event for each generated file or CV export. A
404
+ `files-saved` event includes the saved paths or an error; save errors make
405
+ the command exit nonzero. Needs a full sign-in.
406
+
407
+ | Slash command | What it does |
408
+ |---|---|
409
+ | `/` | Opens the palette: the commands, then your Tabbio skills under **Skills** (loaded once per session). A bare `/` is never sent to the agent; Esc clears it. |
410
+ | `/<skill> [details]` | Runs one of your skills exactly like the app does (`/cv-health-check`). A `/name` that is neither a command nor a skill shows a notice and is not sent. |
411
+ | `/new`, `/threads` | Start a new chat, or resume a recent one |
412
+ | `/mode seeker\|employer`, `/model <id\|auto>` | Switch workspace or model |
413
+ | `/research`, `/image`, `/result` | Research, image and result modes (see below) |
414
+ | `/open <n>` | Opens link `n` of the last result card in the browser; the URL is printed as well |
415
+ | `/open <id>` | Opens a file Tabbio made (a 15-minute preview link) |
416
+ | `/apply <n>` | Asks Tabbio to prepare your application for job `n` of the last job result |
417
+ | `/save <id> [folder]` | Saves a file Tabbio made into `./tabbio-artifacts` (or the folder) |
418
+ | `/show <id>` | Prints a saved page or document in the terminal (saves it first when needed) |
419
+ | `/download [cvId] [pdf\|docx\|txt\|md]` | Saves a CV export; without an id, your main CV |
420
+ | `/login [email you@x.com\|token]`, `/logout` | Sign in or out without leaving the app (see [Signing in](#signing-in)) |
421
+ | `/help`, `/home`, `/exit` | Keys and commands, back to the home screen, quit |
422
+
423
+ Esc interrupts a reply, clears the composer, or goes back to the home screen;
424
+ Ctrl+C twice quits from any screen.
373
425
 
374
426
  ```sh
375
427
  tabbio chat --research on "What changed in UAE labour law this year? Two sources."
376
428
  tabbio chat --result document "A one-page brief on my job search"
377
- tabbio chat --result page --save # every chat on this profile asks for pages until changed
429
+ tabbio chat --result page --remember # every chat on this profile asks for pages until changed
378
430
  ```
379
431
 
380
432
  See [Research, documents, pages and images](#research-documents-pages-and-images).
@@ -387,7 +439,7 @@ tabbio artifacts list --kind document --json
387
439
  tabbio artifacts get art_123
388
440
  tabbio artifacts open art_123 # prints a 15-minute link and opens it (on a terminal)
389
441
  tabbio artifacts open art_123 --variant pdf --print
390
- tabbio artifacts download art_123 # ./<title>.pdf (documents), .pptx (slides), .html (pages), the image
442
+ tabbio artifacts download art_123 # ./tabbio-artifacts/<title>-<id>.pdf (documents), .pptx (slides), .html (pages), the image
391
443
  tabbio artifacts download art_123 --format html --out ~/Downloads --force
392
444
  tabbio artifacts wait art_123 --timeout 2m # exit 0 ready, 1 failed, 7 still rendering
393
445
  tabbio artifacts versions art_123
@@ -396,8 +448,10 @@ tabbio artifacts delete art_123 --yes
396
448
  ```
397
449
 
398
450
  The documents, slide decks, pages and images Tabbio made for you. `download`
399
- never overwrites a file unless you pass `--force`, writes through a temporary
400
- file, and names the file after the title. It fetches a signed link (15
451
+ saves into `./tabbio-artifacts` (or `TABBIO_ARTIFACTS_DIR`) unless `--out`
452
+ says otherwise, prints the path, never overwrites a file (a taken name gets
453
+ `-2`; with `--out`, it stops unless you pass `--force`), writes through a
454
+ temporary file, and names the file after the title. It fetches a signed link (15
401
455
  minutes) without sending your session to the storage host. `delete` asks
402
456
  first unless `--yes` (and a published site of that file goes offline). Needs a
403
457
  full sign-in; with a personal token, `tabbio artifact list|get|read` reach the
@@ -438,8 +492,8 @@ The output format is chosen in this order:
438
492
  | `--plain` | Tab-separated values, no colour. |
439
493
  | `--fields a,b` | Only these fields (tables, plain and JSON). |
440
494
 
441
- `tabbio chat --json` is the exception: it writes NDJSON, one stream event per
442
- line.
495
+ `tabbio chat --json` is the exception: it writes NDJSON, one event per line,
496
+ including local `files-saved` events after the server stream.
443
497
 
444
498
  Colour is decided in this order: `--color` / `--no-color`, then `FORCE_COLOR`,
445
499
  then `NO_COLOR`, then `CLICOLOR_FORCE` / `CLICOLOR`, then `TERM=dumb` (no
@@ -491,6 +545,16 @@ Files live in `$TABBIO_CONFIG_DIR`, else `$XDG_CONFIG_HOME/tabbio`, else
491
545
  The tool catalog cache, update-check stamp and recent-runs history live in
492
546
  `$TABBIO_CACHE_DIR`, else `$XDG_CACHE_HOME/tabbio`, else `~/.cache/tabbio`.
493
547
 
548
+ ### Saved files
549
+
550
+ `chat` and `ask` save what Tabbio makes as soon as it is ready: a page as
551
+ `.html`, a document as `.html` and `.pdf`, slides as `.pptx`, an image as its
552
+ own format, and a CV export as the PDF (or the format you asked for). Files go
553
+ to `TABBIO_ARTIFACTS_DIR`, else `./tabbio-artifacts` in the current directory,
554
+ named `<title>-<last 6 characters of the id>.<ext>`, and are never
555
+ overwritten (`-2`, `-3`). `--no-save` or `TABBIO_NO_ARTIFACT_SAVE=1` turn it
556
+ off.
557
+
494
558
  Other environment variables:
495
559
 
496
560
  | Variable | Effect |
@@ -498,6 +562,8 @@ Other environment variables:
498
562
  | `TABBIO_OUTPUT` | Default output format: `json`, `table` or `plain`. |
499
563
  | `TABBIO_THEME` | `light` or `dark`, when the accent colour guesses the terminal background wrong. |
500
564
  | `TABBIO_DEBUG=1` | Same as `--debug`. |
565
+ | `TABBIO_ARTIFACTS_DIR` | Where `chat`, `ask` and `artifacts download` save files. Default: `./tabbio-artifacts`. |
566
+ | `TABBIO_NO_ARTIFACT_SAVE=1` | Do not save files from `chat` and `ask` (same as `--no-save`). |
501
567
  | `TABBIO_DEVICE_LABEL` | The name this computer shows on the sign-in page and in your sessions. Defaults to the hostname. |
502
568
  | `TABBIO_NO_UPDATE_CHECK=1` | Skip the once-a-day check for a newer version on npm (printed on stderr). Also skipped under `CI`. |
503
569
  | `TABBIO_ALLOW_INSECURE_HTTP=1` | Allow plain `http://` API URLs other than localhost. Development only. |
@@ -595,7 +661,7 @@ controls under the app's message box:
595
661
  They are sent with every message in seeker chats, together with your
596
662
  timezone (`Intl` timezone, so "today" and the daily limits follow your day).
597
663
  Employer chats do not send them; the server ignores them there. Flags apply to
598
- one run; `/research on` in the chat and `tabbio chat … --save` remember the
664
+ one run; `/research on` in the chat and `tabbio chat … --remember` remember the
599
665
  choice for the profile (`profiles.<name>.capabilities` in `config.json`). The
600
666
  status bar shows every control you changed from `auto`. If your account
601
667
  cannot use a control you switched on (the server has it off, you are outside
@@ -617,13 +683,14 @@ What you see in the chat:
617
683
  - **Limits** (daily quota, plan tier, a switched-off feature) as a dim line
618
684
  under the tool row with the reason.
619
685
 
620
- `tabbio chat --json` passes every event through, including `artifact-draft`
686
+ `tabbio chat --json` passes every server event through, including `artifact-draft`
621
687
  (the streaming writer's progress). Research sources are in the
622
688
  `tool-result` payloads of `webSearch`, `readUrl`, `readPdf`,
623
689
  `readGithubRepo` and `readSkill` (`result.sources`); files are in the
624
690
  `tool-result` payloads of `createDocument`, `createPage`, `generateImage`,
625
691
  `updatePage`, `updateDocument` and `editImage` (`result.artifactId`,
626
- `result.status`: `pending`, `ready` or `failed`).
692
+ `result.status`: `pending`, `ready` or `failed`). Generated files also produce
693
+ local `files-saved` events after the turn unless `--no-save` is set.
627
694
 
628
695
  Over MCP (`tabbio research …`, `tabbio artifact …`, `tabbio site …`), the
629
696
  file tools and `site publish` always need your approval first, because they
@@ -1,73 +1,25 @@
1
1
  import { createRequire as __tabbioCreateRequire } from 'node:module';
2
2
  const require = __tabbioCreateRequire(import.meta.url);
3
3
  import {
4
- ApiClient,
5
4
  CliError,
6
5
  ExitCode,
7
- McpSession,
8
6
  configPaths,
7
+ createCommandContext,
9
8
  describeMcpBearer,
10
9
  fingerprint,
11
- getGlobalOptions,
12
10
  getMcpAccessState,
13
11
  heading,
14
- isInteractive,
15
- loadCredentials,
16
12
  notSignedInError,
17
13
  printJson,
18
14
  printKeyValues,
19
15
  readCachedCatalog,
20
16
  relativeTime,
21
- resolveProfileWithSources,
22
- setGlobalOptions,
23
17
  writeOut
24
- } from "./chunk-7LNC3FIV.js";
18
+ } from "./chunk-XR6APSWD.js";
25
19
  import {
26
20
  theme
27
21
  } from "./chunk-VHHZFMIF.js";
28
22
 
29
- // src/core/context.ts
30
- function readGlobalOptions(cmd) {
31
- const raw = cmd ? cmd.optsWithGlobals() : {};
32
- const current = getGlobalOptions();
33
- return setGlobalOptions({
34
- profile: raw.profile ?? current.profile,
35
- apiUrl: raw.apiUrl ?? current.apiUrl,
36
- appUrl: raw.appUrl ?? current.appUrl,
37
- json: Boolean(raw.json ?? current.json),
38
- color: raw.color ?? current.color,
39
- debug: Boolean(raw.debug ?? current.debug),
40
- yes: Boolean(raw.yes ?? current.yes),
41
- quiet: Boolean(raw.quiet ?? current.quiet)
42
- });
43
- }
44
- function createCommandContext(cmd) {
45
- const globals = readGlobalOptions(cmd);
46
- const { profile, sources } = resolveProfileWithSources({
47
- profile: globals.profile,
48
- apiUrl: globals.apiUrl,
49
- appUrl: globals.appUrl
50
- });
51
- const credentials = loadCredentials(profile.name);
52
- const api = new ApiClient(profile, credentials);
53
- let session = null;
54
- return {
55
- globals,
56
- profile,
57
- sources,
58
- credentials,
59
- api,
60
- interactive: isInteractive() && !globals.json,
61
- mcp() {
62
- session ??= McpSession.connect(profile, api.credentials ?? credentials, { api });
63
- return session;
64
- },
65
- async close() {
66
- if (session) await (await session.catch(() => null))?.close();
67
- }
68
- };
69
- }
70
-
71
23
  // src/commands/whoami.ts
72
24
  function pickMe(raw) {
73
25
  if (!raw || typeof raw.id !== "string") return null;
@@ -217,11 +169,9 @@ function registerStatusCommand(program) {
217
169
  }
218
170
 
219
171
  export {
220
- readGlobalOptions,
221
- createCommandContext,
222
172
  registerWhoamiCommand,
223
173
  collectStatus,
224
174
  mcpSummary,
225
175
  registerStatusCommand
226
176
  };
227
- //# sourceMappingURL=chunk-Y6GWIFHI.js.map
177
+ //# sourceMappingURL=chunk-FO6AU5UZ.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/commands/whoami.ts", "../../src/commands/status.ts"],
4
+ "sourcesContent": ["import type { Command } from 'commander';\nimport { createCommandContext } from '../core/context';\nimport { fingerprint } from '../core/credentials';\nimport { notSignedInError } from '../core/errors';\nimport { printJson, writeOut } from '../core/io';\nimport { theme } from '../ui/theme';\n\n/** Fields of `GET /api/users/me` that are safe and useful to print. */\nexport type Me = {\n id: string;\n email: string;\n name: string | null;\n username: string | null;\n role?: string | null;\n status?: string | null;\n isOnboardingCompleted?: boolean;\n};\n\nexport function pickMe(raw: Record<string, unknown> | null | undefined): Me | null {\n if (!raw || typeof raw.id !== 'string') return null;\n const str = (value: unknown) => (typeof value === 'string' ? value : null);\n return {\n id: raw.id,\n email: str(raw.email) ?? '',\n name: str(raw.name),\n username: str(raw.username),\n role: str(raw.role),\n status: str(raw.status),\n ...(typeof raw.isOnboardingCompleted === 'boolean' ? { isOnboardingCompleted: raw.isOnboardingCompleted } : {}),\n };\n}\n\nexport function registerWhoamiCommand(program: Command): void {\n program\n .command('whoami')\n .description('Show the signed-in account')\n .action(async (_opts: unknown, cmd: Command) => {\n const ctx = createCommandContext(cmd);\n const creds = ctx.credentials;\n if (!creds?.accessToken && !creds?.mcpToken) throw notSignedInError(ctx.profile.name);\n\n if (!creds.accessToken) {\n // MCP-only: identity is not queryable without the app session.\n if (ctx.globals.json) {\n printJson({ profile: ctx.profile.name, mode: 'mcp-token', user: creds.user ?? null, mcpToken: fingerprint(creds.mcpToken ?? '') });\n return;\n }\n writeOut(creds.user?.email ?? `Personal MCP token ${fingerprint(creds.mcpToken ?? '')}`);\n writeOut(theme.dim(' MCP-only sign-in; run `tabbio login` for full account details.'));\n return;\n }\n\n const me = pickMe(await ctx.api.json<Record<string, unknown>>('/api/users/me'));\n if (!me) throw notSignedInError(ctx.profile.name);\n if (ctx.globals.json) {\n printJson({ profile: ctx.profile.name, mode: 'session', user: me });\n return;\n }\n writeOut(me.name ? `${me.name} <${me.email}>` : me.email);\n if (me.username) writeOut(theme.dim(` @${me.username} \u00B7 profile ${ctx.profile.name}`));\n });\n}\n", "import type { Command } from 'commander';\nimport type { SubscriptionStateResult } from '@tabbio/types';\nimport { type McpAccessState, getMcpAccessState } from '../core/auth';\nimport { configPaths } from '../core/config';\nimport { type CommandContext, createCommandContext } from '../core/context';\nimport { type Credentials, fingerprint } from '../core/credentials';\nimport { CliError, ExitCode } from '../core/errors';\nimport { type KvRow, heading, printJson, printKeyValues, relativeTime, writeOut } from '../core/io';\nimport { type McpCredentialKind, describeMcpBearer, readCachedCatalog } from '../core/mcp';\nimport { theme } from '../ui/theme';\nimport { type Me, pickMe } from './whoami';\n\nexport type StatusReport = {\n profile: { name: string; source: string; apiUrl: string; appUrl: string; mcpUrl: string; configDir: string };\n signedIn: boolean;\n mode: 'session' | 'mcp-token' | 'none';\n user: Me | { id: string; email: string; name?: string | null } | null;\n plan: string | null;\n session: { token: string; expiresAt: string | null; source: string; refreshable: boolean; valid: boolean | null } | null;\n /** Credential used for MCP tool calls (never the secret itself). */\n mcp: { kind: McpCredentialKind; source: 'env' | 'store'; token: string; label: string } | null;\n pendingApprovals: number | null;\n catalog: { tools: number; fetchedAt: string; fresh: boolean } | null;\n problems: string[];\n};\n\nfunction settledError(result: PromiseSettledResult<unknown>): CliError | null {\n if (result.status === 'fulfilled') return null;\n return result.reason instanceof CliError ? result.reason : null;\n}\n\nexport async function collectStatus(ctx: CommandContext): Promise<StatusReport> {\n const { profile, credentials: creds } = ctx;\n const report: StatusReport = {\n profile: { ...profile, source: ctx.sources.name, configDir: configPaths().dir },\n signedIn: Boolean(creds?.accessToken || creds?.mcpToken),\n mode: creds?.accessToken ? 'session' : creds?.mcpToken ? 'mcp-token' : 'none',\n user: creds?.user ?? null,\n plan: null,\n session: creds?.accessToken\n ? {\n token: fingerprint(creds.accessToken),\n expiresAt: creds.accessTokenExpiresAt ?? null,\n source: creds.origin?.accessToken ?? 'store',\n refreshable: Boolean(creds.refreshToken),\n valid: null,\n }\n : null,\n mcp: mcpSummary(creds),\n pendingApprovals: null,\n catalog: null,\n problems: [],\n };\n\n const cached = readCachedCatalog(profile, creds);\n if (cached) {\n report.catalog = { tools: cached.tools.length, fetchedAt: cached.fetchedAt.toISOString(), fresh: cached.fresh };\n }\n if (!creds?.accessToken) return report;\n\n const [me, access, subscription] = await Promise.allSettled([\n ctx.api.json<Record<string, unknown>>('/api/users/me'),\n getMcpAccessState(ctx.api),\n ctx.api.json<SubscriptionStateResult>('/api/subscription/state'),\n ]);\n\n const authFailure = [me, access, subscription]\n .map(settledError)\n .find((error) => error?.exitCode === ExitCode.Auth);\n if (report.session) report.session.valid = !authFailure;\n if (authFailure) {\n report.signedIn = Boolean(creds.mcpToken);\n report.problems.push(`Session: ${authFailure.message}. Run \\`tabbio login\\`.`);\n }\n\n if (me.status === 'fulfilled') report.user = pickMe(me.value) ?? report.user;\n if (subscription.status === 'fulfilled') report.plan = subscription.value?.snapshot?.tier ?? null;\n\n if (access.status === 'fulfilled') {\n const state: McpAccessState = access.value;\n report.pendingApprovals = state.pendingApprovals?.length ?? 0;\n } else if (!authFailure) {\n const error = settledError(access);\n report.problems.push(`Approvals: ${error?.message ?? 'unavailable'}`);\n }\n return report;\n}\n\nexport function mcpSummary(creds: Credentials | null): StatusReport['mcp'] {\n const bearer = describeMcpBearer(creds);\n if (!bearer) return null;\n const envName = bearer.kind === 'personal-token' ? 'TABBIO_TOKEN' : 'TABBIO_ACCESS_TOKEN';\n const base = bearer.kind === 'app-session' ? 'app session' : `personal token ${bearer.fingerprint}`;\n return {\n kind: bearer.kind,\n source: bearer.source,\n token: bearer.fingerprint,\n label: bearer.source === 'env' ? `${base} (from ${envName})` : base,\n };\n}\n\nfunction printStatus(report: StatusReport): void {\n const user = report.user;\n const who = user ? (user.name ? `${user.name} <${user.email}>` : user.email) : undefined;\n const session = report.session;\n const rows: KvRow[] = [\n ['Profile', `${report.profile.name} ${theme.dim(`(${report.profile.source})`)}`],\n ['API', report.profile.apiUrl],\n ['MCP', report.profile.mcpUrl],\n ['Account', who ?? (report.signedIn ? theme.dim('personal MCP token (no session)') : theme.warn('not signed in'))],\n ['Plan', report.plan],\n [\n 'Session',\n session\n ? [\n session.token,\n session.source === 'env' ? 'from TABBIO_ACCESS_TOKEN' : null,\n session.expiresAt ? `expires ${relativeTime(new Date(session.expiresAt))}` : null,\n session.refreshable ? 'auto-refresh' : null,\n session.valid === false ? theme.error('expired') : null,\n ]\n .filter(Boolean)\n .join(' \u00B7 ')\n : undefined,\n ],\n ['Tools via', report.mcp?.label],\n [\n 'Approvals',\n report.pendingApprovals === null\n ? undefined\n : report.pendingApprovals > 0\n ? `${theme.accent(`${report.pendingApprovals} pending`)} ${theme.dim('\u2192 tabbio approvals')}`\n : 'none pending',\n ],\n [\n 'Catalog',\n report.catalog\n ? `${report.catalog.tools} tools \u00B7 cached ${relativeTime(new Date(report.catalog.fetchedAt))}${report.catalog.fresh ? '' : theme.dim(' (stale)')}`\n : undefined,\n ],\n ];\n writeOut(heading('Tabbio CLI status'));\n printKeyValues(rows);\n for (const problem of report.problems) writeOut(`${theme.warn(theme.symbols.warning)} ${problem}`);\n if (!report.signedIn) writeOut(theme.dim('Run `tabbio login` to sign in.'));\n}\n\nexport function registerStatusCommand(program: Command): void {\n program\n .command('status')\n .description('Show profile, account, plan, tokens (fingerprints only), approvals and catalog age')\n .action(async (_opts: unknown, cmd: Command) => {\n const ctx = createCommandContext(cmd);\n const report = await collectStatus(ctx);\n if (ctx.globals.json) printJson(report);\n else printStatus(report);\n if (!report.signedIn) process.exitCode = ExitCode.Auth;\n });\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,7 +1,6 @@
1
1
  import { createRequire as __tabbioCreateRequire } from 'node:module';
2
2
  const require = __tabbioCreateRequire(import.meta.url);
3
3
  import {
4
- SPINNER_INTERVAL_MS,
5
4
  theme
6
5
  } from "./chunk-VHHZFMIF.js";
7
6
 
@@ -10,6 +9,39 @@ var ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*
10
9
  function stripAnsi(text) {
11
10
  return text.replace(ANSI_PATTERN, "");
12
11
  }
12
+ function safeTerminalText(text) {
13
+ return stripAnsi(text).replace(/\s+/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
14
+ }
15
+ var TerminalTextSanitizer = class {
16
+ state = "plain";
17
+ reset() {
18
+ this.state = "plain";
19
+ }
20
+ write(text) {
21
+ let clean = "";
22
+ for (const char of text) {
23
+ const code = char.charCodeAt(0);
24
+ if (this.state === "plain") {
25
+ if (code === 27) this.state = "escape";
26
+ else if (code === 155) this.state = "csi";
27
+ else if (code === 144 || code === 157 || code === 158 || code === 159) this.state = "string";
28
+ else if (char === "\n" || char === " " || code >= 32 && !(code >= 127 && code <= 159)) clean += char;
29
+ } else if (this.state === "escape") {
30
+ if (char === "[") this.state = "csi";
31
+ else if (char === "]" || char === "P" || char === "^" || char === "_") this.state = "string";
32
+ else if (code !== 27) this.state = "plain";
33
+ } else if (this.state === "csi") {
34
+ if (code >= 64 && code <= 126) this.state = "plain";
35
+ else if (code === 27) this.state = "escape";
36
+ } else if (this.state === "string") {
37
+ if (code === 7 || code === 156) this.state = "plain";
38
+ else if (code === 27) this.state = "string-escape";
39
+ } else if (char === "\\" || code === 7 || code === 156) this.state = "plain";
40
+ else if (code !== 27) this.state = "string";
41
+ }
42
+ return clean;
43
+ }
44
+ };
13
45
  function charWidth(code) {
14
46
  if (code < 32 || code >= 127 && code < 160) return 0;
15
47
  if (code >= 768 && code <= 879 || code >= 8203 && code <= 8207 || code === 65039) return 0;
@@ -146,92 +178,10 @@ function estimateHeight(text, width) {
146
178
  return text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / cols)), 0);
147
179
  }
148
180
 
149
- // src/ui/components/Spinner.tsx
150
- import { Text } from "ink";
151
- import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
152
- import { jsx, jsxs } from "react/jsx-runtime";
153
- var TickContext = createContext(0);
154
- var SubscribeContext = createContext(null);
155
- function SpinnerProvider({ children }) {
156
- const [tick, setTick] = useState(0);
157
- const [subscribers, setSubscribers] = useState(0);
158
- useEffect(() => {
159
- if (subscribers === 0) return void 0;
160
- const id = setInterval(() => setTick((t) => (t + 1) % 1e6), SPINNER_INTERVAL_MS);
161
- return () => clearInterval(id);
162
- }, [subscribers]);
163
- const subscribe = useCallback(() => {
164
- setSubscribers((n) => n + 1);
165
- return () => setSubscribers((n) => Math.max(0, n - 1));
166
- }, []);
167
- return /* @__PURE__ */ jsx(SubscribeContext.Provider, { value: subscribe, children: /* @__PURE__ */ jsx(TickContext.Provider, { value: tick, children }) });
168
- }
169
- function useClockTick(active = true) {
170
- const subscribe = useContext(SubscribeContext);
171
- const tick = useContext(TickContext);
172
- useEffect(() => active && subscribe ? subscribe() : void 0, [active, subscribe]);
173
- return tick;
174
- }
175
- var Spinner = memo(function Spinner2({ label }) {
176
- const tick = useClockTick(theme.animations);
177
- const frames = theme.spinnerFrames;
178
- const glyph = theme.animations ? frames[tick % frames.length] ?? theme.symbols.pending : theme.symbols.pending;
179
- return /* @__PURE__ */ jsxs(Text, { children: [
180
- theme.accent(glyph),
181
- label ? ` ${label}` : ""
182
- ] });
183
- });
184
- var Elapsed = memo(function Elapsed2({ since, prefix = "" }) {
185
- useClockTick(true);
186
- return /* @__PURE__ */ jsx(Text, { children: theme.dim(`${prefix}${formatElapsed(Date.now() - since)}`) });
187
- });
188
- function Working({ label, since }) {
189
- const text = useMemo(() => theme.dim(label), [label]);
190
- return /* @__PURE__ */ jsxs(Text, { children: [
191
- /* @__PURE__ */ jsx(Spinner, {}),
192
- " ",
193
- text,
194
- " ",
195
- /* @__PURE__ */ jsx(Elapsed, { since })
196
- ] });
197
- }
198
-
199
- // src/ui/layout.tsx
200
- import { Box, useStdout } from "ink";
201
- import { useEffect as useEffect2, useState as useState2 } from "react";
202
- import { jsx as jsx2 } from "react/jsx-runtime";
203
- var PAGE_MARGIN = 2;
204
- function readSize(stdout) {
205
- return { columns: stdout.columns || 80, rows: stdout.rows || 24 };
206
- }
207
- function useTerminalSize() {
208
- const { stdout } = useStdout();
209
- const [size, setSize] = useState2(() => readSize(stdout));
210
- useEffect2(() => {
211
- const onResize = () => setSize(readSize(stdout));
212
- stdout.on("resize", onResize);
213
- return () => {
214
- stdout.off("resize", onResize);
215
- };
216
- }, [stdout]);
217
- return size;
218
- }
219
- function fromTerminal(columns) {
220
- return columns - pageMargin(columns) * 2;
221
- }
222
- function pageMargin(columns) {
223
- return columns < 50 ? 1 : PAGE_MARGIN;
224
- }
225
- function contentWidth(columns) {
226
- return Math.max(20, columns - pageMargin(columns) * 2);
227
- }
228
- function Page({ columns, children }) {
229
- const margin = pageMargin(columns);
230
- return /* @__PURE__ */ jsx2(Box, { flexDirection: "column", paddingLeft: margin, paddingRight: margin, width: columns, children });
231
- }
232
-
233
181
  export {
234
182
  stripAnsi,
183
+ safeTerminalText,
184
+ TerminalTextSanitizer,
235
185
  displayWidth,
236
186
  truncate,
237
187
  truncateStart,
@@ -245,15 +195,6 @@ export {
245
195
  wrapWords,
246
196
  safeJson,
247
197
  clipLines,
248
- estimateHeight,
249
- SpinnerProvider,
250
- useClockTick,
251
- Spinner,
252
- Working,
253
- useTerminalSize,
254
- fromTerminal,
255
- pageMargin,
256
- contentWidth,
257
- Page
198
+ estimateHeight
258
199
  };
259
- //# sourceMappingURL=chunk-NAWA6NCZ.js.map
200
+ //# sourceMappingURL=chunk-NRFVKK3S.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/ui/text.ts"],
4
+ "sourcesContent": ["/**\n * Pure text helpers for the Ink screens: ANSI-aware width, truncation,\n * padding and the small formatters every screen shares. No React here.\n */\nimport { theme } from './theme';\n\n// eslint-disable-next-line no-control-regex\nconst ANSI_PATTERN = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007|(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~])/g;\n\nexport function stripAnsi(text: string): string {\n return text.replace(ANSI_PATTERN, '');\n}\n\n/** Server-supplied text must never carry terminal control bytes into Ink or stdout. */\nexport function safeTerminalText(text: string): string {\n // eslint-disable-next-line no-control-regex\n return stripAnsi(text).replace(/\\s+/g, ' ').replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, '').trim();\n}\n\n/** Streamed answer text may split an ANSI escape across SSE chunks. */\nexport class TerminalTextSanitizer {\n private state: 'plain' | 'escape' | 'csi' | 'string' | 'string-escape' = 'plain';\n\n reset(): void {\n this.state = 'plain';\n }\n\n write(text: string): string {\n let clean = '';\n for (const char of text) {\n const code = char.charCodeAt(0);\n if (this.state === 'plain') {\n if (code === 0x1b) this.state = 'escape';\n else if (code === 0x9b) this.state = 'csi';\n else if (code === 0x90 || code === 0x9d || code === 0x9e || code === 0x9f) this.state = 'string';\n else if (char === '\\n' || char === '\\t' || (code >= 0x20 && !(code >= 0x7f && code <= 0x9f))) clean += char;\n } else if (this.state === 'escape') {\n if (char === '[') this.state = 'csi';\n else if (char === ']' || char === 'P' || char === '^' || char === '_') this.state = 'string';\n else if (code !== 0x1b) this.state = 'plain';\n } else if (this.state === 'csi') {\n if (code >= 0x40 && code <= 0x7e) this.state = 'plain';\n else if (code === 0x1b) this.state = 'escape';\n } else if (this.state === 'string') {\n if (code === 0x07 || code === 0x9c) this.state = 'plain';\n else if (code === 0x1b) this.state = 'string-escape';\n } else if (char === '\\\\' || code === 0x07 || code === 0x9c) this.state = 'plain';\n else if (code !== 0x1b) this.state = 'string';\n }\n return clean;\n }\n}\n\nfunction charWidth(code: number): number {\n if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;\n if ((code >= 0x300 && code <= 0x36f) || (code >= 0x200b && code <= 0x200f) || code === 0xfe0f) return 0;\n const wide =\n (code >= 0x1100 && code <= 0x115f) ||\n (code >= 0x2e80 && code <= 0xa4cf) ||\n (code >= 0xac00 && code <= 0xd7a3) ||\n (code >= 0xf900 && code <= 0xfaff) ||\n (code >= 0xfe30 && code <= 0xfe4f) ||\n (code >= 0xff00 && code <= 0xff60) ||\n (code >= 0xffe0 && code <= 0xffe6) ||\n (code >= 0x1f300 && code <= 0x1faff) ||\n (code >= 0x20000 && code <= 0x3fffd);\n return wide ? 2 : 1;\n}\n\n/** Terminal cells a string occupies (ANSI stripped; wide glyphs count 2). */\nexport function displayWidth(text: string): number {\n let width = 0;\n for (const char of stripAnsi(text)) width += charWidth(char.codePointAt(0) ?? 0);\n return width;\n}\n\n/** Cuts a plain string to `max` cells, ending with the theme ellipsis when shortened. */\nexport function truncate(text: string, max: number, ellipsis: string = theme.symbols.ellipsis): string {\n if (max <= 0) return '';\n if (displayWidth(text) <= max) return text;\n const budget = Math.max(0, max - displayWidth(ellipsis));\n let out = '';\n let width = 0;\n for (const char of text) {\n const w = charWidth(char.codePointAt(0) ?? 0);\n if (width + w > budget) break;\n out += char;\n width += w;\n }\n return displayWidth(ellipsis) > max ? out : `${out}${ellipsis}`;\n}\n\n/** Keeps the end of a string (for inputs scrolled to the cursor). */\nexport function truncateStart(text: string, max: number, ellipsis: string = theme.symbols.ellipsis): string {\n if (max <= 0) return '';\n if (displayWidth(text) <= max) return text;\n const chars = [...text];\n const budget = Math.max(0, max - displayWidth(ellipsis));\n let out = '';\n let width = 0;\n for (let i = chars.length - 1; i >= 0; i -= 1) {\n const char = chars[i] as string;\n const w = charWidth(char.codePointAt(0) ?? 0);\n if (width + w > budget) break;\n out = char + out;\n width += w;\n }\n return `${ellipsis}${out}`;\n}\n\nexport function padEnd(text: string, width: number): string {\n return text + ' '.repeat(Math.max(0, width - displayWidth(text)));\n}\n\nexport function padStart(text: string, width: number): string {\n return ' '.repeat(Math.max(0, width - displayWidth(text))) + text;\n}\n\n/** One line: collapses whitespace (newlines included). */\nexport function oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** \"Job Search\" / \"job_search\" \u2192 \"Job search\". */\nexport function sentenceCase(text: string): string {\n const clean = oneLine(text.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2'));\n if (!clean) return clean;\n const lower = clean\n .split(' ')\n .map((word, i) => (i > 0 && /^[A-Z][a-z]/.test(word) ? word.toLowerCase() : word))\n .join(' ');\n return lower.charAt(0).toUpperCase() + lower.slice(1);\n}\n\n/** `0.8s`, `12s`, `1m 04s`; under a second shows tenths. */\nexport function formatElapsed(ms: number): string {\n const safe = Math.max(0, ms);\n if (safe < 10_000) return `${(safe / 1000).toFixed(1)}s`;\n if (safe < 60_000) return `${Math.floor(safe / 1000)}s`;\n const minutes = Math.floor(safe / 60_000);\n const seconds = Math.floor((safe % 60_000) / 1000);\n return `${minutes}m ${String(seconds).padStart(2, '0')}s`;\n}\n\n/** `apr_2Hq9\u2026k1`: enough of an id to recognise it, never the whole thing. */\nexport function shortId(id: string, head = 8, tail = 2): string {\n return id.length > head + tail + 1 ? `${id.slice(0, head)}${theme.symbols.ellipsis}${id.slice(-tail)}` : id;\n}\n\nfunction scalarText(value: unknown): string | null {\n if (value === null || value === undefined) return null;\n if (typeof value === 'string') return value;\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) {\n return value.map(String).join(', ');\n }\n return null;\n}\n\n/**\n * Dim one-line argument summary for tool rows: scalar values only, strings\n * quoted, ids bare (`\"product designer\" \u00B7 Dubai \u00B7 cv_8f2`).\n */\nexport function summarizeArgs(args: Record<string, unknown> | undefined, max = 60): string {\n if (!args) return '';\n const sep = ` ${theme.symbols.middot} `;\n const parts: string[] = [];\n for (const [key, value] of Object.entries(args)) {\n if (key === 'userId' || key === 'companyId') continue;\n if (value === true) {\n parts.push(key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase());\n continue;\n }\n const text = scalarText(value);\n if (text === null || text === '' || typeof value === 'boolean') continue;\n const clean = oneLine(text);\n const part = /id$/i.test(key) || /^[\\w-]+$/.test(clean) ? clean : `\"${truncate(clean, 32)}\"`;\n const next = [...parts, part].join(sep);\n if (displayWidth(next) > max) {\n if (parts.length === 0) parts.push(truncate(part, max));\n break;\n }\n parts.push(part);\n }\n return parts.join(sep);\n}\n\n/** Word-wraps plain text into at most `maxLines` lines (the last one ends with `\u2026` when cut). */\nexport function wrapWords(text: string, width: number, maxLines = Number.POSITIVE_INFINITY): string[] {\n const words = oneLine(text).split(' ').filter(Boolean);\n const lines: string[] = [];\n let line = '';\n for (const word of words) {\n const candidate = line ? `${line} ${word}` : word;\n if (displayWidth(candidate) <= width) {\n line = candidate;\n continue;\n }\n if (line) lines.push(line);\n line = displayWidth(word) > width ? truncate(word, width) : word;\n if (lines.length === maxLines) break;\n }\n if (line && lines.length < maxLines) lines.push(line);\n if (lines.length === maxLines && words.join(' ') !== lines.join(' ')) {\n lines[maxLines - 1] = truncate(`${lines[maxLines - 1]} \u2026`, width);\n }\n return lines.slice(0, maxLines);\n}\n\n/** Pretty JSON, never throwing (cycles and BigInt fall back to String()). */\nexport function safeJson(value: unknown, indent = 2): string {\n try {\n return JSON.stringify(value, null, indent) ?? String(value);\n } catch {\n return String(value);\n }\n}\n\n/** Keeps the first `max` lines and reports how many were cut. */\nexport function clipLines(text: string, max: number): { lines: string[]; hidden: number } {\n const lines = text.split('\\n');\n if (lines.length <= max) return { lines, hidden: 0 };\n return { lines: lines.slice(0, Math.max(0, max)), hidden: lines.length - Math.max(0, max) };\n}\n\n/** Rough visual height of text wrapped at `width` (used to budget live regions). */\nexport function estimateHeight(text: string, width: number): number {\n const cols = Math.max(1, width);\n return text.split('\\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / cols)), 0);\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,106 @@
1
+ import { createRequire as __tabbioCreateRequire } from 'node:module';
2
+ const require = __tabbioCreateRequire(import.meta.url);
3
+ import {
4
+ formatElapsed
5
+ } from "./chunk-NRFVKK3S.js";
6
+ import {
7
+ SPINNER_INTERVAL_MS,
8
+ theme
9
+ } from "./chunk-VHHZFMIF.js";
10
+
11
+ // src/ui/components/Spinner.tsx
12
+ import { Text } from "ink";
13
+ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
15
+ var TickContext = createContext(0);
16
+ var SubscribeContext = createContext(null);
17
+ function SpinnerProvider({ children }) {
18
+ const [tick, setTick] = useState(0);
19
+ const [subscribers, setSubscribers] = useState(0);
20
+ useEffect(() => {
21
+ if (subscribers === 0) return void 0;
22
+ const id = setInterval(() => setTick((t) => (t + 1) % 1e6), SPINNER_INTERVAL_MS);
23
+ return () => clearInterval(id);
24
+ }, [subscribers]);
25
+ const subscribe = useCallback(() => {
26
+ setSubscribers((n) => n + 1);
27
+ return () => setSubscribers((n) => Math.max(0, n - 1));
28
+ }, []);
29
+ return /* @__PURE__ */ jsx(SubscribeContext.Provider, { value: subscribe, children: /* @__PURE__ */ jsx(TickContext.Provider, { value: tick, children }) });
30
+ }
31
+ function useClockTick(active = true) {
32
+ const subscribe = useContext(SubscribeContext);
33
+ const tick = useContext(TickContext);
34
+ useEffect(() => active && subscribe ? subscribe() : void 0, [active, subscribe]);
35
+ return tick;
36
+ }
37
+ var Spinner = memo(function Spinner2({ label }) {
38
+ const tick = useClockTick(theme.animations);
39
+ const frames = theme.spinnerFrames;
40
+ const glyph = theme.animations ? frames[tick % frames.length] ?? theme.symbols.pending : theme.symbols.pending;
41
+ return /* @__PURE__ */ jsxs(Text, { children: [
42
+ theme.accent(glyph),
43
+ label ? ` ${label}` : ""
44
+ ] });
45
+ });
46
+ var Elapsed = memo(function Elapsed2({ since, prefix = "" }) {
47
+ useClockTick(true);
48
+ return /* @__PURE__ */ jsx(Text, { children: theme.dim(`${prefix}${formatElapsed(Date.now() - since)}`) });
49
+ });
50
+ function Working({ label, since }) {
51
+ const text = useMemo(() => theme.dim(label), [label]);
52
+ return /* @__PURE__ */ jsxs(Text, { children: [
53
+ /* @__PURE__ */ jsx(Spinner, {}),
54
+ " ",
55
+ text,
56
+ " ",
57
+ /* @__PURE__ */ jsx(Elapsed, { since })
58
+ ] });
59
+ }
60
+
61
+ // src/ui/layout.tsx
62
+ import { Box, useStdout } from "ink";
63
+ import { useEffect as useEffect2, useState as useState2 } from "react";
64
+ import { jsx as jsx2 } from "react/jsx-runtime";
65
+ var PAGE_MARGIN = 2;
66
+ function readSize(stdout) {
67
+ return { columns: stdout.columns || 80, rows: stdout.rows || 24 };
68
+ }
69
+ function useTerminalSize() {
70
+ const { stdout } = useStdout();
71
+ const [size, setSize] = useState2(() => readSize(stdout));
72
+ useEffect2(() => {
73
+ const onResize = () => setSize(readSize(stdout));
74
+ stdout.on("resize", onResize);
75
+ return () => {
76
+ stdout.off("resize", onResize);
77
+ };
78
+ }, [stdout]);
79
+ return size;
80
+ }
81
+ function fromTerminal(columns) {
82
+ return columns - pageMargin(columns) * 2;
83
+ }
84
+ function pageMargin(columns) {
85
+ return columns < 50 ? 1 : PAGE_MARGIN;
86
+ }
87
+ function contentWidth(columns) {
88
+ return Math.max(20, columns - pageMargin(columns) * 2);
89
+ }
90
+ function Page({ columns, children }) {
91
+ const margin = pageMargin(columns);
92
+ return /* @__PURE__ */ jsx2(Box, { flexDirection: "column", paddingLeft: margin, paddingRight: margin, width: columns, children });
93
+ }
94
+
95
+ export {
96
+ SpinnerProvider,
97
+ useClockTick,
98
+ Spinner,
99
+ Working,
100
+ useTerminalSize,
101
+ fromTerminal,
102
+ pageMargin,
103
+ contentWidth,
104
+ Page
105
+ };
106
+ //# sourceMappingURL=chunk-TP3CRQJD.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/ui/components/Spinner.tsx", "../../src/ui/layout.tsx"],
4
+ "sourcesContent": ["import { Text } from 'ink';\nimport { type ReactNode, createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport { SPINNER_INTERVAL_MS, theme } from '../theme';\nimport { formatElapsed } from '../text';\n\n/**\n * One shared 80 ms clock for every spinner and elapsed counter (research\n * \u00A75.10). The interval only runs while at least one consumer is mounted and\n * animations are on, and it is cleared on unmount.\n */\nconst TickContext = createContext(0);\nconst SubscribeContext = createContext<(() => () => void) | null>(null);\n\nexport function SpinnerProvider({ children }: { children: ReactNode }) {\n const [tick, setTick] = useState(0);\n const [subscribers, setSubscribers] = useState(0);\n\n useEffect(() => {\n if (subscribers === 0) return undefined;\n const id = setInterval(() => setTick((t) => (t + 1) % 1_000_000), SPINNER_INTERVAL_MS);\n return () => clearInterval(id);\n }, [subscribers]);\n\n const subscribe = useCallback(() => {\n setSubscribers((n) => n + 1);\n return () => setSubscribers((n) => Math.max(0, n - 1));\n }, []);\n\n return (\n <SubscribeContext.Provider value={subscribe}>\n <TickContext.Provider value={tick}>{children}</TickContext.Provider>\n </SubscribeContext.Provider>\n );\n}\n\n/** Current clock tick; subscribes the caller to the shared clock while `active`. */\nexport function useClockTick(active = true): number {\n const subscribe = useContext(SubscribeContext);\n const tick = useContext(TickContext);\n useEffect(() => (active && subscribe ? subscribe() : undefined), [active, subscribe]);\n return tick;\n}\n\n/** Braille spinner in the accent colour; a static `\u25D0` without animation. */\nexport const Spinner = memo(function Spinner({ label }: { label?: string }) {\n const tick = useClockTick(theme.animations);\n const frames = theme.spinnerFrames;\n const glyph = theme.animations ? (frames[tick % frames.length] ?? theme.symbols.pending) : theme.symbols.pending;\n return (\n <Text>\n {theme.accent(glyph)}\n {label ? ` ${label}` : ''}\n </Text>\n );\n});\n\n/** Live elapsed time since `since` (dim), driven by the shared clock. */\nexport const Elapsed = memo(function Elapsed({ since, prefix = '' }: { since: number; prefix?: string }) {\n useClockTick(true);\n return <Text>{theme.dim(`${prefix}${formatElapsed(Date.now() - since)}`)}</Text>;\n});\n\n/** Spinner + label + elapsed, the standard \"working\" line. */\nexport function Working({ label, since }: { label: string; since: number }) {\n const text = useMemo(() => theme.dim(label), [label]);\n return (\n <Text>\n <Spinner /> {text} <Elapsed since={since} />\n </Text>\n );\n}\n", "import { Box, useStdout } from 'ink';\nimport { type ReactNode, useEffect, useState } from 'react';\n\n/** Page gutter (research mockups indent everything by two cells). */\nexport const PAGE_MARGIN = 2;\n\nexport type TerminalSize = { columns: number; rows: number };\n\nfunction readSize(stdout: NodeJS.WriteStream): TerminalSize {\n return { columns: stdout.columns || 80, rows: stdout.rows || 24 };\n}\n\n/** Terminal size that follows `resize` events (listener removed on unmount). */\nexport function useTerminalSize(): TerminalSize {\n const { stdout } = useStdout();\n const [size, setSize] = useState<TerminalSize>(() => readSize(stdout));\n useEffect(() => {\n const onResize = () => setSize(readSize(stdout));\n stdout.on('resize', onResize);\n return () => {\n stdout.off('resize', onResize);\n };\n }, [stdout]);\n return size;\n}\n\n/**\n * Breakpoints are in terminal columns (research \u00A75.5/\u00A75.6); screens get the\n * content width, so compare with `fromTerminal(n)`.\n */\nexport function fromTerminal(columns: number): number {\n return columns - pageMargin(columns) * 2;\n}\n\n/** Gutter on each side: two cells, one on very narrow terminals. */\nexport function pageMargin(columns: number): number {\n return columns < 50 ? 1 : PAGE_MARGIN;\n}\n\n/** Usable content width inside the page gutter. */\nexport function contentWidth(columns: number): number {\n return Math.max(20, columns - pageMargin(columns) * 2);\n}\n\n/**\n * Page frame: a gutter on both sides and no background. Ink clears the whole\n * terminal when the live region reaches the terminal height, so screens size\n * themselves from `rows` and stay at least one line shorter.\n */\nexport function Page({ columns, children }: { columns: number; children: ReactNode }) {\n const margin = pageMargin(columns);\n return (\n <Box flexDirection=\"column\" paddingLeft={margin} paddingRight={margin} width={columns}>\n {children}\n </Box>\n );\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["Spinner", "Elapsed", "useEffect", "useState", "jsx"]
7
+ }