hacklab 0.7.2 → 0.7.5
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 +16 -1
- package/dist/api-client.d.ts +18 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +40 -0
- package/dist/api-client.js.map +1 -0
- package/dist/card.d.ts +66 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/card.js +151 -0
- package/dist/card.js.map +1 -0
- package/dist/commands/chat.d.ts +21 -1
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +109 -14
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/drop.d.ts +1 -1
- package/dist/commands/drop.d.ts.map +1 -1
- package/dist/commands/drop.js +6 -1
- package/dist/commands/drop.js.map +1 -1
- package/dist/commands/essay.d.ts.map +1 -1
- package/dist/commands/essay.js +13 -0
- package/dist/commands/essay.js.map +1 -1
- package/dist/commands/hacker.d.ts +2 -0
- package/dist/commands/hacker.d.ts.map +1 -0
- package/dist/commands/hacker.js +104 -0
- package/dist/commands/hacker.js.map +1 -0
- package/dist/commands/join.d.ts.map +1 -1
- package/dist/commands/join.js +22 -0
- package/dist/commands/join.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +13 -0
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +2 -0
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/profile.d.ts +60 -0
- package/dist/commands/profile.d.ts.map +1 -0
- package/dist/commands/profile.js +478 -0
- package/dist/commands/profile.js.map +1 -0
- package/dist/commands/project.d.ts +5 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +387 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +14 -0
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +92 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/posthog.d.ts +9 -0
- package/dist/posthog.d.ts.map +1 -0
- package/dist/posthog.js +20 -0
- package/dist/posthog.js.map +1 -0
- package/dist/project-infer.d.ts +50 -0
- package/dist/project-infer.d.ts.map +1 -0
- package/dist/project-infer.js +211 -0
- package/dist/project-infer.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +25 -5
- package/dist/registry.js.map +1 -1
- package/dist/ui.d.ts +12 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +56 -0
- package/dist/ui.js.map +1 -1
- package/dist/utils/npmGlobal.d.ts +30 -0
- package/dist/utils/npmGlobal.d.ts.map +1 -0
- package/dist/utils/npmGlobal.js +69 -0
- package/dist/utils/npmGlobal.js.map +1 -0
- package/dist/utils/updateCheck.d.ts +6 -0
- package/dist/utils/updateCheck.d.ts.map +1 -1
- package/dist/utils/updateCheck.js +7 -2
- package/dist/utils/updateCheck.js.map +1 -1
- package/package.json +3 -1
- package/dist/commands/brag.d.ts +0 -5
- package/dist/commands/brag.d.ts.map +0 -1
- package/dist/commands/brag.js +0 -87
- package/dist/commands/brag.js.map +0 -1
- package/dist/project-brag.d.ts +0 -58
- package/dist/project-brag.d.ts.map +0 -1
- package/dist/project-brag.js +0 -270
- package/dist/project-brag.js.map +0 -1
package/README.md
CHANGED
|
@@ -74,12 +74,27 @@ scan local AI usage → see your rank → pick a username → sign in with GitHu
|
|
|
74
74
|
- `hacklab logout` — clear your saved session on this machine.
|
|
75
75
|
- `hacklab config <key> <value>` — set config (`cursor-api-key`, `cursor-email`).
|
|
76
76
|
Bare `hacklab config` prints the effective values and where each came from.
|
|
77
|
-
- `hacklab
|
|
77
|
+
- `hacklab project` — publish the repo you're standing in as a project. No
|
|
78
|
+
manifest: it reads the git remote, README, and package.json, shows a preview,
|
|
79
|
+
and publishes on confirm (`--yes` to skip, `--json` for agents). Re-running
|
|
80
|
+
refreshes the same project (slug derives from the repo name) without losing
|
|
81
|
+
its publish date or screenshots; if the live site (package.json `homepage` or
|
|
82
|
+
`--live <url>`) has an `og:image`, that becomes the screenshot. Override any
|
|
83
|
+
inferred field with `--title`, `--description`, `--live`, `--tags a,b,c`,
|
|
84
|
+
`--slug`. Manage the set with `hacklab project list` and
|
|
85
|
+
`hacklab project delete <slug>`.
|
|
78
86
|
- `hacklab org` — hub for company management. If you already own a company, pick
|
|
79
87
|
a field, type the new value, and it saves as you go. If you don't own one yet,
|
|
80
88
|
it offers to claim or create. Subcommands: `hacklab org claim` (take ownership
|
|
81
89
|
of a YC-seeded company you're a member of, or whose domain matches your login
|
|
82
90
|
email) and `hacklab org create` (register a brand-new company from the CLI).
|
|
91
|
+
- `hacklab profile` — view and edit your own profile. Bare `profile` (or
|
|
92
|
+
`profile view`) shows it; `profile edit` is an org-style autosave editor;
|
|
93
|
+
`profile set <field> <value>` writes one field (`--clear` unsets, handles like
|
|
94
|
+
`x mattbratos` become full links); `profile apply profile.yaml` writes many
|
|
95
|
+
fields in one shot — handy for agents setting up your profile. Fields: `name`,
|
|
96
|
+
`bio`, `website`, `blog`, `x`, `youtube`, `instagram`, `rss`, `open-to-work`.
|
|
97
|
+
Add `--json` to `view`/`set`/`apply` for machine-readable output.
|
|
83
98
|
- `hacklab --version` / `hacklab --help`.
|
|
84
99
|
|
|
85
100
|
## Cursor usage
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Session } from './session.js';
|
|
2
|
+
/** Print the versioned JSON error envelope and exit 1 (agent mode). */
|
|
3
|
+
export declare function emitJsonError(code: string, message: string): never;
|
|
4
|
+
/** Login gate: returns the session or exits with mode-appropriate copy. */
|
|
5
|
+
export declare function requireSession(json: boolean): Promise<Session>;
|
|
6
|
+
/**
|
|
7
|
+
* Human-readable message for a non-2xx envelope body. 401 gets the per-backend
|
|
8
|
+
* re-login hint (an --env mismatch names the exact login command), 429 the
|
|
9
|
+
* slow-down copy; anything else relays the server's message.
|
|
10
|
+
*/
|
|
11
|
+
export declare function apiErrorMessage(status: number, body: {
|
|
12
|
+
error?: {
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
} | null, session: Session): string;
|
|
16
|
+
/** Read a failed Response's envelope and shape it via apiErrorMessage. */
|
|
17
|
+
export declare function readApiError(res: Response, session: Session): Promise<string>;
|
|
18
|
+
//# sourceMappingURL=api-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,OAAO,EAAoB,MAAM,cAAc,CAAA;AAO1E,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAGlE;AAED,2EAA2E;AAC3E,wBAAsB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CASpE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,EAC7C,OAAO,EAAE,OAAO,GACf,MAAM,CAIR;AAED,0EAA0E;AAC1E,wBAAsB,YAAY,CAChC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,MAAM,CAAC,CAKjB"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { loadSession, unauthorizedHint } from './session.js';
|
|
2
|
+
import { dim, error, info } from './ui.js';
|
|
3
|
+
// Shared plumbing for authenticated agent-friendly commands (profile, hacker):
|
|
4
|
+
// one login gate, one JSON error envelope, one reader for the server's
|
|
5
|
+
// {schemaVersion, error} shape — so the 401/429 copy can't drift per command.
|
|
6
|
+
/** Print the versioned JSON error envelope and exit 1 (agent mode). */
|
|
7
|
+
export function emitJsonError(code, message) {
|
|
8
|
+
console.log(JSON.stringify({ schemaVersion: 1, error: { code, message } }));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
/** Login gate: returns the session or exits with mode-appropriate copy. */
|
|
12
|
+
export async function requireSession(json) {
|
|
13
|
+
const session = await loadSession();
|
|
14
|
+
if (!session) {
|
|
15
|
+
if (json)
|
|
16
|
+
emitJsonError('unauthorized', 'not logged in');
|
|
17
|
+
error('not logged in');
|
|
18
|
+
info(`run ${dim('hacklab login')} first`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
return session;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Human-readable message for a non-2xx envelope body. 401 gets the per-backend
|
|
25
|
+
* re-login hint (an --env mismatch names the exact login command), 429 the
|
|
26
|
+
* slow-down copy; anything else relays the server's message.
|
|
27
|
+
*/
|
|
28
|
+
export function apiErrorMessage(status, body, session) {
|
|
29
|
+
if (status === 401)
|
|
30
|
+
return unauthorizedHint(session);
|
|
31
|
+
if (status === 429)
|
|
32
|
+
return 'slow down. try again in a minute.';
|
|
33
|
+
return body?.error?.message ?? `request failed (${status})`;
|
|
34
|
+
}
|
|
35
|
+
/** Read a failed Response's envelope and shape it via apiErrorMessage. */
|
|
36
|
+
export async function readApiError(res, session) {
|
|
37
|
+
const body = (await res.json().catch(() => null));
|
|
38
|
+
return apiErrorMessage(res.status, body, session);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=api-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAgB,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAC1E,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAE1C,+EAA+E;AAC/E,uEAAuE;AACvE,8EAA8E;AAE9E,uEAAuE;AACvE,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,OAAe;IACzD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,CAAA;IAC3E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAED,2EAA2E;AAC3E,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAa;IAChD,MAAM,OAAO,GAAG,MAAM,WAAW,EAAE,CAAA;IACnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,IAAI,IAAI;YAAE,aAAa,CAAC,cAAc,EAAE,eAAe,CAAC,CAAA;QACxD,KAAK,CAAC,eAAe,CAAC,CAAA;QACtB,IAAI,CAAC,OAAO,GAAG,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAA;QACzC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAc,EACd,IAA6C,EAC7C,OAAgB;IAEhB,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAA;IACpD,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,mCAAmC,CAAA;IAC9D,OAAO,IAAI,EAAE,KAAK,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAA;AAC7D,CAAC;AAED,0EAA0E;AAC1E,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,GAAa,EACb,OAAgB;IAEhB,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAExC,CAAA;IACR,OAAO,eAAe,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;AACnD,CAAC"}
|
package/dist/card.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type HackerCardData = {
|
|
2
|
+
handle: string;
|
|
3
|
+
displayName: string | null;
|
|
4
|
+
bio: string | null;
|
|
5
|
+
joinedAt: string;
|
|
6
|
+
claimedAt: string | null;
|
|
7
|
+
level: number;
|
|
8
|
+
tokensTotal: number;
|
|
9
|
+
tokens30d: number;
|
|
10
|
+
estimatedCost: number;
|
|
11
|
+
rank: number | null;
|
|
12
|
+
topModels30d: {
|
|
13
|
+
model: string;
|
|
14
|
+
tokens: number;
|
|
15
|
+
pct: number;
|
|
16
|
+
}[];
|
|
17
|
+
currentStreak: number;
|
|
18
|
+
longestStreak: number;
|
|
19
|
+
activeDays30: number;
|
|
20
|
+
activity30: number[];
|
|
21
|
+
counts: {
|
|
22
|
+
projects: number;
|
|
23
|
+
essays: number;
|
|
24
|
+
drops: number;
|
|
25
|
+
followers: number;
|
|
26
|
+
};
|
|
27
|
+
recent: {
|
|
28
|
+
projects: {
|
|
29
|
+
title: string;
|
|
30
|
+
description: string | null;
|
|
31
|
+
}[];
|
|
32
|
+
essays: {
|
|
33
|
+
title: string;
|
|
34
|
+
publishedAt: string | null;
|
|
35
|
+
}[];
|
|
36
|
+
drops: {
|
|
37
|
+
text: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}[];
|
|
40
|
+
};
|
|
41
|
+
links: {
|
|
42
|
+
profile: string;
|
|
43
|
+
website: string | null;
|
|
44
|
+
github: string | null;
|
|
45
|
+
x: string | null;
|
|
46
|
+
};
|
|
47
|
+
openToWork: boolean;
|
|
48
|
+
};
|
|
49
|
+
export type RenderCardOptions = {
|
|
50
|
+
/** Terminal columns. Clamped to [MIN_WIDTH, DESIGN_WIDTH]. */
|
|
51
|
+
columns?: number;
|
|
52
|
+
now?: Date;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Render the full one-screen card as an array of lines (caller joins with \n).
|
|
56
|
+
* Pure: no I/O, deterministic given `now`. Every user-supplied string is passed
|
|
57
|
+
* through stripControl before it reaches the terminal.
|
|
58
|
+
*/
|
|
59
|
+
export declare function renderCard(card: HackerCardData, opts?: RenderCardOptions): string[];
|
|
60
|
+
/**
|
|
61
|
+
* The compact in-chat card (`/who`): 4 lines, no rules, no sparkline. Same
|
|
62
|
+
* strip discipline. Returned as separate lines so the chat renderer can push
|
|
63
|
+
* them as one Entry per row (preserving the one-row-per-entry invariant).
|
|
64
|
+
*/
|
|
65
|
+
export declare function renderCompact(card: HackerCardData): string[];
|
|
66
|
+
//# sourceMappingURL=card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"AAuBA,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,YAAY,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC9D,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9E,MAAM,EAAE;QACN,QAAQ,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,EAAE,CAAA;QACzD,MAAM,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;SAAE,EAAE,CAAA;QACvD,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAC7C,CAAA;IACD,KAAK,EAAE;QACL,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;QACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;QACrB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KACjB,CAAA;IACD,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAQD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,IAAI,CAAA;CACX,CAAA;AAyFD;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,cAAc,EACpB,IAAI,GAAE,iBAAsB,GAC3B,MAAM,EAAE,CAiHV;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,EAAE,CAY5D"}
|
package/dist/card.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { getTitle } from './belt.js';
|
|
2
|
+
import { formatTokens } from './scanners/util.js';
|
|
3
|
+
import { dim, displayWidth, mint, padEndTo, rankColor, stripControl, truncateToWidth, white, } from './ui.js';
|
|
4
|
+
const DESIGN_WIDTH = 68;
|
|
5
|
+
const MIN_WIDTH = 45; // below this the fixed 30d row can't fit; wrapping is accepted
|
|
6
|
+
/** 8 log-scaled buckets for the 30-day sparkline. Zero renders dim, lit cells mint. */
|
|
7
|
+
const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
8
|
+
function resolveWidth(columns) {
|
|
9
|
+
const cols = columns ?? DESIGN_WIDTH;
|
|
10
|
+
return Math.max(MIN_WIDTH, Math.min(DESIGN_WIDTH, cols));
|
|
11
|
+
}
|
|
12
|
+
/** ISO string → `YYYY-MM-DD`. */
|
|
13
|
+
function ymd(iso) {
|
|
14
|
+
return iso.slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
function ageDays(iso, now) {
|
|
17
|
+
return Math.max(0, Math.floor((now.getTime() - Date.parse(iso)) / 86_400_000));
|
|
18
|
+
}
|
|
19
|
+
/** A left segment and a right segment on one line, right-aligned to `width`. */
|
|
20
|
+
function justify(width, leftColored, rightColored) {
|
|
21
|
+
const gap = Math.max(1, width - displayWidth(leftColored) - displayWidth(rightColored));
|
|
22
|
+
return leftColored + ' '.repeat(gap) + rightColored;
|
|
23
|
+
}
|
|
24
|
+
function rule(width) {
|
|
25
|
+
return dim('─'.repeat(width));
|
|
26
|
+
}
|
|
27
|
+
/** `████ L32 SHINOBI` — coloured band + dim romaji rank. Belt derived from level. */
|
|
28
|
+
function beltBadge(level) {
|
|
29
|
+
const band = rankColor(level, '████');
|
|
30
|
+
const label = dim(` L${level} ${getTitle(level).toUpperCase()}`);
|
|
31
|
+
return band + label;
|
|
32
|
+
}
|
|
33
|
+
function sparkline(values) {
|
|
34
|
+
const max = Math.max(0, ...values);
|
|
35
|
+
const denom = Math.log2(max + 1);
|
|
36
|
+
return values
|
|
37
|
+
.map((v) => {
|
|
38
|
+
if (v <= 0)
|
|
39
|
+
return dim('▁');
|
|
40
|
+
const frac = denom > 0 ? Math.log2(v + 1) / denom : 0;
|
|
41
|
+
const idx = Math.min(SPARK.length - 1, Math.max(0, Math.round(frac * (SPARK.length - 1))));
|
|
42
|
+
return mint(SPARK[idx] ?? '█');
|
|
43
|
+
})
|
|
44
|
+
.join('');
|
|
45
|
+
}
|
|
46
|
+
/** A `label value` row; the label is dim, the value plain, truncated to width. */
|
|
47
|
+
function labelled(width, label, value) {
|
|
48
|
+
const head = dim(padEndTo(label, 8)); // "joined ", "streak ", …
|
|
49
|
+
const budget = width - displayWidth(head);
|
|
50
|
+
return head + truncateToWidth(value, budget);
|
|
51
|
+
}
|
|
52
|
+
/** A recent-list section: `HEADER N` then rows, or `HEADER 0 nothing yet`. */
|
|
53
|
+
function section(width, label, total, rows) {
|
|
54
|
+
if (total === 0)
|
|
55
|
+
return [dim(`${label} 0 nothing yet`)];
|
|
56
|
+
return [`${label} ${total}`, ...rows.map((r) => truncateToWidth(r, width))];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Projects/essays row-budget rule: ≤3 rows below the header. When more than 3
|
|
60
|
+
* exist, show 2 items + `+N more`; otherwise show them all.
|
|
61
|
+
*/
|
|
62
|
+
function recentRows(total, items, row) {
|
|
63
|
+
if (total > 3) {
|
|
64
|
+
return [...items.slice(0, 2).map(row), ` +${total - 2} more`];
|
|
65
|
+
}
|
|
66
|
+
return items.map(row);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Render the full one-screen card as an array of lines (caller joins with \n).
|
|
70
|
+
* Pure: no I/O, deterministic given `now`. Every user-supplied string is passed
|
|
71
|
+
* through stripControl before it reaches the terminal.
|
|
72
|
+
*/
|
|
73
|
+
export function renderCard(card, opts = {}) {
|
|
74
|
+
const width = resolveWidth(opts.columns);
|
|
75
|
+
const now = opts.now ?? new Date();
|
|
76
|
+
const lines = [];
|
|
77
|
+
// Line 1 — identity + belt.
|
|
78
|
+
const name = card.displayName ? ` · ${stripControl(card.displayName)}` : '';
|
|
79
|
+
const identity = white(truncateToWidth(`${stripControl(card.handle)}${name}`, width - displayWidth(beltBadge(card.level)) - 1));
|
|
80
|
+
lines.push(justify(width, identity, beltBadge(card.level)));
|
|
81
|
+
// Line 2 — bio (left) + open-to-work dot (right). Skipped when both absent.
|
|
82
|
+
const bio = card.bio
|
|
83
|
+
? truncateToWidth(stripControl(card.bio), width - 16)
|
|
84
|
+
: '';
|
|
85
|
+
const otw = card.openToWork ? `${mint('●')} open to work` : '';
|
|
86
|
+
if (bio || otw)
|
|
87
|
+
lines.push(justify(width, bio, otw));
|
|
88
|
+
lines.push(rule(width));
|
|
89
|
+
// Identity block.
|
|
90
|
+
const claimed = card.claimedAt
|
|
91
|
+
? `claimed ${ymd(card.claimedAt)}`
|
|
92
|
+
: 'not claimed yet';
|
|
93
|
+
const rank = card.rank != null ? ` rank #${card.rank}` : '';
|
|
94
|
+
lines.push(labelled(width, 'joined', `${ymd(card.joinedAt)} (${ageDays(card.joinedAt, now)}d) ${claimed}${rank}`));
|
|
95
|
+
// Activity block, with the two null-state collapses.
|
|
96
|
+
if (card.tokensTotal === 0) {
|
|
97
|
+
lines.push(dim('no activity yet. run hacklab sync to bank the proof.'));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(labelled(width, 'streak', `${card.currentStreak}d current · ${card.longestStreak}d longest active ${card.activeDays30}/30d`));
|
|
101
|
+
const cost = `$${Math.round(card.estimatedCost).toLocaleString('en-US')} est. cost`;
|
|
102
|
+
lines.push(labelled(width, 'tokens', `${formatTokens(card.tokensTotal)} burned · ${formatTokens(card.tokens30d)} last 30d · ${cost}`));
|
|
103
|
+
// Models + sparkline collapse when there's no 30-day activity.
|
|
104
|
+
if (card.tokens30d > 0) {
|
|
105
|
+
const models = card.topModels30d
|
|
106
|
+
.slice(0, 2)
|
|
107
|
+
.map((m) => `${m.model} ${m.pct}%`)
|
|
108
|
+
.join(' · ');
|
|
109
|
+
if (models)
|
|
110
|
+
lines.push(labelled(width, 'models', models));
|
|
111
|
+
lines.push(dim(padEndTo('30d', 8)) + sparkline(card.activity30));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
lines.push(rule(width));
|
|
115
|
+
// Recent work.
|
|
116
|
+
lines.push(...section(width, 'PROJECTS', card.counts.projects, recentRows(card.counts.projects, card.recent.projects, (p) => ` ${padEndTo(truncateToWidth(stripControl(p.title), 16), 16)} ${stripControl(p.description ?? '')}`)));
|
|
117
|
+
lines.push(...section(width, 'ESSAYS', card.counts.essays, recentRows(card.counts.essays, card.recent.essays, (e) => ` ${e.publishedAt ? ymd(e.publishedAt) : ' '} ${stripControl(e.title)}`)));
|
|
118
|
+
// Drops: 1 item, no "+N more"; the count lives in the header.
|
|
119
|
+
if (card.counts.drops === 0) {
|
|
120
|
+
lines.push(dim('DROPS 0 nothing yet'));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const last = card.recent.drops[0];
|
|
124
|
+
lines.push(`DROPS ${card.counts.drops}${last ? ` · last ${ymd(last.createdAt)}` : ''}`);
|
|
125
|
+
if (last) {
|
|
126
|
+
lines.push(truncateToWidth(` "${stripControl(last.text)}"`, width));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
lines.push(rule(width));
|
|
130
|
+
lines.push(dim(`--json for the full payload · hacklab.so/${card.handle}`));
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The compact in-chat card (`/who`): 4 lines, no rules, no sparkline. Same
|
|
135
|
+
* strip discipline. Returned as separate lines so the chat renderer can push
|
|
136
|
+
* them as one Entry per row (preserving the one-row-per-entry invariant).
|
|
137
|
+
*/
|
|
138
|
+
export function renderCompact(card) {
|
|
139
|
+
const name = card.displayName ? ` · ${stripControl(card.displayName)}` : '';
|
|
140
|
+
const otw = card.openToWork ? ` · ${mint('●')} open to work` : '';
|
|
141
|
+
const joined = card.claimedAt
|
|
142
|
+
? `joined ${ageDays(card.claimedAt, new Date())}d ago`
|
|
143
|
+
: 'not claimed yet';
|
|
144
|
+
return [
|
|
145
|
+
`${white(stripControl(card.handle))}${name} · ${beltBadge(card.level)}${otw}`,
|
|
146
|
+
`${formatTokens(card.tokensTotal)} tokens · ${formatTokens(card.tokens30d)}/30d · ${card.currentStreak}d streak · ${joined}`,
|
|
147
|
+
`${card.counts.projects} projects · ${card.counts.essays} essays · ${card.counts.drops} drops`,
|
|
148
|
+
dim(`hacklab.so/${card.handle}`),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=card.js.map
|
package/dist/card.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"card.js","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"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"}
|
package/dist/commands/chat.d.ts
CHANGED
|
@@ -8,9 +8,29 @@ type RenderableMessage = {
|
|
|
8
8
|
content: string;
|
|
9
9
|
senderLevel?: number | null;
|
|
10
10
|
};
|
|
11
|
-
export declare function stripControl(s: string): string;
|
|
12
11
|
export declare function formatLine(m: RenderableMessage, now?: Date): string;
|
|
13
12
|
export declare function formatPendingLine(handle: string, content: string, tag: string, at?: string, now?: Date): string;
|
|
13
|
+
export type LineIntent = {
|
|
14
|
+
kind: 'empty';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'command';
|
|
17
|
+
cmd: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'message';
|
|
21
|
+
content: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Decide what a typed line means. A single leading slash is a local command and
|
|
25
|
+
* never posts. A DOUBLED leading slash is how you post a message that starts with
|
|
26
|
+
* a slash: it posts (and displays) verbatim, both slashes intact. Nothing is
|
|
27
|
+
* stripped, so the optimistic placeholder, the POST body, and the server echo all
|
|
28
|
+
* agree (reconcile-by-content) — and the server can reject single-leading-slash
|
|
29
|
+
* content (a stale client posting a command it doesn't know is local) without
|
|
30
|
+
* catching the doubled form. Pure + exported so these semantics are unit-tested
|
|
31
|
+
* without driving readline.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseLine(input: string): LineIntent;
|
|
14
34
|
export declare function chat(args: string[]): Promise<void>;
|
|
15
35
|
export {};
|
|
16
36
|
//# sourceMappingURL=chat.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,eAAe,CAAA;AAgDxE,wBAAgB,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,MAAM,CAExD;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,CASxD;AAKD,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAKR;AAED,KAAK,iBAAiB,GAAG;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAAA;AAED,wBAAgB,UAAU,CACxB,CAAC,EAAE,iBAAiB,EACpB,GAAG,GAAE,IAAiB,GACrB,MAAM,CAQR;AAQD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,EAAE,GAAE,MAAiC,EACrC,GAAG,GAAE,IAAiB,GACrB,MAAM,CAIR;AA8MD,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAExC;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CASnD;AA4UD,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,iBAkCxC"}
|
package/dist/commands/chat.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
|
+
import { renderCompact } from '../card.js';
|
|
3
|
+
import { captureEvent } from '../posthog.js';
|
|
2
4
|
import { loadSession, resolveAppUrl } from '../session.js';
|
|
3
|
-
import { bold, dim, error, green, info, rankColor, red, success, } from '../ui.js';
|
|
5
|
+
import { bold, dim, error, green, info, rankColor, red, stripControl, success, } from '../ui.js';
|
|
4
6
|
// The chat command is CLI-first: every subcommand takes --json so an AI agent
|
|
5
7
|
// can drive the channel and DMs programmatically, while the default output is a
|
|
6
8
|
// human-readable terminal view. Thin wrappers over the /api/chat/* routes.
|
|
@@ -95,13 +97,6 @@ export function formatTimestamp(when, now = new Date()) {
|
|
|
95
97
|
const prefix = datePrefix(d, now);
|
|
96
98
|
return prefix ? `${prefix} ${time}` : time;
|
|
97
99
|
}
|
|
98
|
-
// Strip C0 control characters + DEL from untrusted message content before
|
|
99
|
-
// printing it to the terminal. Without this, a hacker could embed ANSI escape
|
|
100
|
-
// sequences in a message to clear/garble or hijack any viewer's terminal.
|
|
101
|
-
export function stripControl(s) {
|
|
102
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control chars to remove them
|
|
103
|
-
return s.replace(/[\x00-\x1f\x7f]/g, '');
|
|
104
|
-
}
|
|
105
100
|
export function formatLine(m, now = new Date()) {
|
|
106
101
|
const lvl = m.senderLevel != null ? dim(` lv${m.senderLevel}`) : '';
|
|
107
102
|
// Colour the author's handle in their belt/rank colour (see rankColor).
|
|
@@ -173,6 +168,9 @@ async function chatPost(words, json) {
|
|
|
173
168
|
return;
|
|
174
169
|
}
|
|
175
170
|
success('posted.');
|
|
171
|
+
await captureEvent(session.handle, 'cli_chat_message_posted', {
|
|
172
|
+
content_length: content.length,
|
|
173
|
+
});
|
|
176
174
|
}
|
|
177
175
|
async function chatHistory(args, json) {
|
|
178
176
|
const before = flagValue(args, '--before');
|
|
@@ -217,10 +215,13 @@ async function chatDms(json) {
|
|
|
217
215
|
}
|
|
218
216
|
for (const c of data.conversations) {
|
|
219
217
|
const unread = c.unread_count > 0 ? ` ${green(`(${c.unread_count} new)`)}` : '';
|
|
218
|
+
// Both the partner handle and the last-message preview are untrusted user
|
|
219
|
+
// input wrapped in ANSI before printing — strip control chars so a crafted
|
|
220
|
+
// handle or DM body can't inject escape sequences into the viewer's terminal.
|
|
220
221
|
const preview = c.last_message_content
|
|
221
|
-
? dim(` — ${c.last_message_content}`)
|
|
222
|
+
? dim(` — ${stripControl(c.last_message_content)}`)
|
|
222
223
|
: '';
|
|
223
|
-
console.log(`${bold(`@${c.partner_handle}`)}${unread}${preview}`);
|
|
224
|
+
console.log(`${bold(`@${stripControl(c.partner_handle)}`)}${unread}${preview}`);
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
227
|
async function chatDm(args, json) {
|
|
@@ -251,6 +252,9 @@ async function chatDm(args, json) {
|
|
|
251
252
|
return;
|
|
252
253
|
}
|
|
253
254
|
success(`sent to @${handle}.`);
|
|
255
|
+
await captureEvent(session.handle, 'cli_dm_sent', {
|
|
256
|
+
content_length: message.length,
|
|
257
|
+
});
|
|
254
258
|
return;
|
|
255
259
|
}
|
|
256
260
|
const res = await fetch(base, {
|
|
@@ -294,6 +298,28 @@ async function chatFlag(args, json) {
|
|
|
294
298
|
}
|
|
295
299
|
success('flagged for review.');
|
|
296
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Decide what a typed line means. A single leading slash is a local command and
|
|
303
|
+
* never posts. A DOUBLED leading slash is how you post a message that starts with
|
|
304
|
+
* a slash: it posts (and displays) verbatim, both slashes intact. Nothing is
|
|
305
|
+
* stripped, so the optimistic placeholder, the POST body, and the server echo all
|
|
306
|
+
* agree (reconcile-by-content) — and the server can reject single-leading-slash
|
|
307
|
+
* content (a stale client posting a command it doesn't know is local) without
|
|
308
|
+
* catching the doubled form. Pure + exported so these semantics are unit-tested
|
|
309
|
+
* without driving readline.
|
|
310
|
+
*/
|
|
311
|
+
export function parseLine(input) {
|
|
312
|
+
const content = input.trim();
|
|
313
|
+
if (!content)
|
|
314
|
+
return { kind: 'empty' };
|
|
315
|
+
if (content.startsWith('//'))
|
|
316
|
+
return { kind: 'message', content };
|
|
317
|
+
if (content.startsWith('/')) {
|
|
318
|
+
const [cmd, ...args] = content.slice(1).trim().split(/\s+/);
|
|
319
|
+
return { kind: 'command', cmd: cmd ?? '', args };
|
|
320
|
+
}
|
|
321
|
+
return { kind: 'message', content };
|
|
322
|
+
}
|
|
297
323
|
// Interactive live channel: short-poll the public tail and print new messages
|
|
298
324
|
// above a readline prompt; press enter to post. CLI-first design means this is a
|
|
299
325
|
// thin loop over the same endpoints the agent subcommands use.
|
|
@@ -318,6 +344,8 @@ async function chatLive() {
|
|
|
318
344
|
function renderEntry(e, now) {
|
|
319
345
|
if (e.kind === 'msg')
|
|
320
346
|
return formatLine(e.m, now);
|
|
347
|
+
if (e.kind === 'text')
|
|
348
|
+
return e.text;
|
|
321
349
|
const tag = e.failed ? red(`[failed: ${e.failed}]`) : dim('[sending]');
|
|
322
350
|
return formatPendingLine(myHandle ?? 'you', e.content, tag, e.at, now);
|
|
323
351
|
}
|
|
@@ -340,6 +368,61 @@ async function chatLive() {
|
|
|
340
368
|
process.stdout.write(`${renderEntry(e, new Date())}\n`);
|
|
341
369
|
process.stdout.write(rl.getPrompt() + inProgress);
|
|
342
370
|
}
|
|
371
|
+
// Append a local one-row line (slash-command output). Goes through appendEntry
|
|
372
|
+
// as a `text` entry, so it counts as exactly one log row.
|
|
373
|
+
function appendText(text) {
|
|
374
|
+
appendEntry({ kind: 'text', text });
|
|
375
|
+
}
|
|
376
|
+
const SLASH_HELP = [
|
|
377
|
+
dim('/who <handle> look up a hacker'),
|
|
378
|
+
dim('/help this list'),
|
|
379
|
+
dim('//<text> post a message that starts with a slash (shown as //)'),
|
|
380
|
+
dim('(dm someone with `hacklab chat dm <handle> <message>`)'),
|
|
381
|
+
];
|
|
382
|
+
// Dispatch a `/command`. Runs locally, never posts. Async commands (/who)
|
|
383
|
+
// append their rows when the fetch lands — the prompt was already restored by
|
|
384
|
+
// the caller, so the user keeps typing while the lookup is in flight.
|
|
385
|
+
async function handleSlash(cmd, args) {
|
|
386
|
+
if (cmd === 'who')
|
|
387
|
+
return whoLookup(args[0]);
|
|
388
|
+
if (cmd === 'help') {
|
|
389
|
+
for (const line of SLASH_HELP)
|
|
390
|
+
appendText(line);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
appendText(dim(`unknown command: /${stripControl(cmd)}. try /help`));
|
|
394
|
+
}
|
|
395
|
+
// `/who <handle>` — the compact card, appended one row per line. Prints
|
|
396
|
+
// nothing while the fetch is in flight (no placeholder to rewrite).
|
|
397
|
+
async function whoLookup(rawHandle) {
|
|
398
|
+
const handle = rawHandle?.replace(/^@/, '');
|
|
399
|
+
if (!handle) {
|
|
400
|
+
appendText(dim('usage: /who <handle>'));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const safe = stripControl(handle);
|
|
404
|
+
try {
|
|
405
|
+
const res = await fetch(`${base}/api/hackers/${encodeURIComponent(handle)}?src=chat`, { headers: { Authorization: `Bearer ${session.token}` } });
|
|
406
|
+
if (res.status === 404) {
|
|
407
|
+
appendText(dim(`no hacker named "${safe}"`));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
appendText(dim(`could not look up ${safe} (${res.status})`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const data = (await res.json());
|
|
415
|
+
if (!data.hacker) {
|
|
416
|
+
appendText(dim(`no hacker named "${safe}"`));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
for (const line of renderCompact(data.hacker))
|
|
420
|
+
appendText(line);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
appendText(dim('could not reach hacklab'));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
343
426
|
// Rewrite an entry's existing line in place (it must still be on screen), then
|
|
344
427
|
// restore the prompt + in-progress input. Used to swap an optimistic
|
|
345
428
|
// placeholder for the confirmed (or failed) message with no second line.
|
|
@@ -435,8 +518,8 @@ async function chatLive() {
|
|
|
435
518
|
await poll();
|
|
436
519
|
const timer = setInterval(poll, 1500);
|
|
437
520
|
rl.on('line', async (input) => {
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
521
|
+
const intent = parseLine(input);
|
|
522
|
+
if (intent.kind === 'empty') {
|
|
440
523
|
rl.prompt();
|
|
441
524
|
return;
|
|
442
525
|
}
|
|
@@ -449,12 +532,24 @@ async function chatLive() {
|
|
|
449
532
|
readline.moveCursor(process.stdout, 0, -echoRows);
|
|
450
533
|
readline.cursorTo(process.stdout, 0);
|
|
451
534
|
readline.clearScreenDown(process.stdout);
|
|
535
|
+
// A local command never posts. Restore the prompt first, then let the
|
|
536
|
+
// (possibly async) command append its rows via appendEntry — one row per
|
|
537
|
+
// entry, so rowsUpOf stays correct even mid optimistic-send.
|
|
538
|
+
if (intent.kind === 'command') {
|
|
539
|
+
rl.prompt();
|
|
540
|
+
void handleSlash(intent.cmd, intent.args);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// parseLine already stripped the escaping slash from a `//` message, so the
|
|
544
|
+
// placeholder and the POST body agree — the poll reconciles optimistic sends
|
|
545
|
+
// by exact content match, and a mismatch sticks the placeholder on [sending].
|
|
546
|
+
const postContent = intent.content;
|
|
452
547
|
// Faded placeholder, one row above the freshly drawn prompt. Written inline
|
|
453
548
|
// (not via appendEntry) so we can hand control back with rl.prompt(), which
|
|
454
549
|
// keeps readline's cursor model correct for the next keystroke.
|
|
455
550
|
const placeholder = {
|
|
456
551
|
kind: 'pending',
|
|
457
|
-
content,
|
|
552
|
+
content: postContent,
|
|
458
553
|
at: new Date().toISOString(),
|
|
459
554
|
};
|
|
460
555
|
log.push(placeholder);
|
|
@@ -467,7 +562,7 @@ async function chatLive() {
|
|
|
467
562
|
'Content-Type': 'application/json',
|
|
468
563
|
Authorization: `Bearer ${session.token}`,
|
|
469
564
|
},
|
|
470
|
-
body: JSON.stringify({ content }),
|
|
565
|
+
body: JSON.stringify({ content: postContent }),
|
|
471
566
|
});
|
|
472
567
|
if (!res.ok) {
|
|
473
568
|
failPending(placeholder, await readError(res));
|