@agentprojectcontext/apx 1.19.1 → 1.21.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/package.json +4 -37
- package/src/core/messages-store.js +5 -0
- package/src/daemon/plugins/telegram.js +84 -31
- package/src/daemon/super-agent.js +1 -1
- package/src/tui/_shims/cli-logo.ts +10 -8
- package/src/tui/_shims/opencode-any.ts +2 -2
- package/src/tui/_shims/prompt-display.ts +45 -0
- package/src/tui/_shims/util-locale.ts +101 -7
- package/src/tui/context/sdk-apx.tsx +43 -16
- package/src/tui/context/sync-apx.tsx +47 -1
- package/src/tui/context/sync.tsx +39 -4
- package/src/tui/routes/session/index.tsx +123 -70
- package/src/tui/routes/session/sidebar-apx.tsx +90 -0
- package/src/tui/tsconfig.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentprojectcontext/apx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -10,13 +10,11 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"apx": "./src/cli/index.js",
|
|
12
12
|
"apx-daemon": "./src/daemon/index.js",
|
|
13
|
-
"apx-mcp": "./src/mcp/index.js"
|
|
14
|
-
"apx-ng": "./dist/cli/index.js"
|
|
13
|
+
"apx-mcp": "./src/mcp/index.js"
|
|
15
14
|
},
|
|
16
15
|
"files": [
|
|
17
16
|
"src/",
|
|
18
17
|
"skills/",
|
|
19
|
-
"dist/",
|
|
20
18
|
"README.md"
|
|
21
19
|
],
|
|
22
20
|
"engines": {
|
|
@@ -26,9 +24,6 @@
|
|
|
26
24
|
"start": "node src/daemon/index.js",
|
|
27
25
|
"smoke": "node src/daemon/smoke.js",
|
|
28
26
|
"test": "node --test --test-reporter=spec tests/*.test.js",
|
|
29
|
-
"build": "node scripts/build-cli.js",
|
|
30
|
-
"build:watch": "node scripts/build-cli.js --watch",
|
|
31
|
-
"typecheck": "tsc --noEmit -p tsconfig.cli.json",
|
|
32
27
|
"upgrade": "pnpm install && pnpm add -g .",
|
|
33
28
|
"prepack": "node scripts/sync-apc-skill.js",
|
|
34
29
|
"postinstall": "node src/cli/postinstall.js"
|
|
@@ -36,50 +31,23 @@
|
|
|
36
31
|
"packageManager": "pnpm@10.25.0",
|
|
37
32
|
"dependencies": {
|
|
38
33
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
|
-
"@opentui/core": "^0.2.8",
|
|
40
|
-
"@opentui/keymap": "^0.2.8",
|
|
41
|
-
"@opentui/solid": "^0.2.8",
|
|
42
|
-
"@solid-primitives/event-bus": "^1.1.3",
|
|
43
|
-
"@solid-primitives/keyboard": "^1.3.5",
|
|
44
|
-
"@solid-primitives/scheduled": "^1.5.3",
|
|
45
|
-
"ansi-regex": "^6.2.2",
|
|
46
34
|
"chalk": "^5.6.2",
|
|
47
35
|
"cli-cursor": "^5.0.0",
|
|
48
|
-
"cli-sound": "^1.1.3",
|
|
49
|
-
"clipboardy": "^5.3.1",
|
|
50
36
|
"cron-parser": "^5.5.0",
|
|
51
|
-
"effect": "^3.21.2",
|
|
52
37
|
"express": "^4.21.0",
|
|
53
|
-
"
|
|
54
|
-
"jsonc-parser": "^3.3.1",
|
|
55
|
-
"node-fetch": "^3.3.2",
|
|
56
|
-
"open": "^11.0.0",
|
|
57
|
-
"opentui-spinner": "^0.0.6",
|
|
58
|
-
"react": "^19.2.6",
|
|
59
|
-
"remeda": "^2.34.1",
|
|
60
|
-
"semver": "^7.8.0",
|
|
61
|
-
"solid-js": "^1.9.12",
|
|
62
|
-
"strip-ansi": "^7.2.0",
|
|
63
|
-
"yargs": "^18.0.0",
|
|
64
|
-
"zod": "^3.25.76"
|
|
38
|
+
"node-fetch": "^3.3.2"
|
|
65
39
|
},
|
|
66
40
|
"optionalDependencies": {
|
|
67
41
|
"fast-glob": "^3.3.2",
|
|
68
|
-
"puppeteer": "^22.0.0"
|
|
69
|
-
"ws": "^8.18.0"
|
|
42
|
+
"puppeteer": "^22.0.0"
|
|
70
43
|
},
|
|
71
44
|
"devDependencies": {
|
|
72
|
-
"@babel/core": "^7.29.0",
|
|
73
45
|
"@semantic-release/changelog": "^6.0.3",
|
|
74
46
|
"@semantic-release/git": "^10.0.1",
|
|
75
47
|
"@types/node": "^25.7.0",
|
|
76
|
-
"@types/yargs": "^17.0.35",
|
|
77
|
-
"babel-preset-solid": "^1.9.12",
|
|
78
48
|
"better-sqlite3": "^11.3.0",
|
|
79
49
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
80
|
-
"electron": "^33.4.11",
|
|
81
50
|
"esbuild": "^0.28.0",
|
|
82
|
-
"esbuild-plugin-solid": "^0.6.0",
|
|
83
51
|
"typescript": "^6.0.3"
|
|
84
52
|
},
|
|
85
53
|
"keywords": [
|
|
@@ -94,7 +62,6 @@
|
|
|
94
62
|
"pnpm": {
|
|
95
63
|
"onlyBuiltDependencies": [
|
|
96
64
|
"better-sqlite3",
|
|
97
|
-
"electron",
|
|
98
65
|
"puppeteer"
|
|
99
66
|
]
|
|
100
67
|
},
|
|
@@ -403,6 +403,11 @@ export function getRecentTelegramTurnsFromFs({
|
|
|
403
403
|
all.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
404
404
|
const filtered = all
|
|
405
405
|
.filter((m) => String(m.meta?.chat_id ?? "") === String(chat_id))
|
|
406
|
+
// Only conversational turns become model context. `tool` / `system`
|
|
407
|
+
// entries are kept in the store for the audit trail (and for channels
|
|
408
|
+
// that DO render tools), but replaying them as assistant messages would
|
|
409
|
+
// look like bogus answers to the model.
|
|
410
|
+
.filter((m) => m.type === "user" || m.type === "agent")
|
|
406
411
|
.slice(-limit);
|
|
407
412
|
return filtered.map((m) => {
|
|
408
413
|
const role = m.direction === "in" ? "user" : "assistant";
|
|
@@ -626,10 +626,69 @@ class ChannelPoller {
|
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
628
|
|
|
629
|
-
// Fallback: super-agent
|
|
630
|
-
|
|
629
|
+
// Fallback: super-agent — STREAMED.
|
|
630
|
+
// Each iteration's assistant text is sent to Telegram as its own message
|
|
631
|
+
// the moment the model produces it (its running commentary), so the user
|
|
632
|
+
// sees a real back-and-forth instead of one giant final dump. Tool calls
|
|
633
|
+
// are logged to the message store — visible via apx log / apx search and
|
|
634
|
+
// to channels that render tools — but NEVER sent to Telegram; tools are
|
|
635
|
+
// internal. The conversation saved on disk is the full, real exchange;
|
|
636
|
+
// Telegram is just the prose-only view of it.
|
|
631
637
|
let saUsage = null;
|
|
638
|
+
let streamedCount = 0;
|
|
639
|
+
let lastStreamedText = "";
|
|
632
640
|
if (!replyText && isSuperAgentEnabled(this.globalConfig)) {
|
|
641
|
+
const onEvent = async (ev) => {
|
|
642
|
+
try {
|
|
643
|
+
if (ev.type === "assistant_text" && ev.text) {
|
|
644
|
+
const piece = stripThinking(ev.text).trim();
|
|
645
|
+
if (!piece) return;
|
|
646
|
+
await this._send({ chat_id, text: piece });
|
|
647
|
+
lastStreamedText = piece;
|
|
648
|
+
streamedCount += 1;
|
|
649
|
+
appendGlobalMessage({
|
|
650
|
+
channel: "telegram",
|
|
651
|
+
direction: "out",
|
|
652
|
+
type: "agent",
|
|
653
|
+
actor_id: "apx",
|
|
654
|
+
agent_slug: "apx",
|
|
655
|
+
author: "apx",
|
|
656
|
+
body: piece,
|
|
657
|
+
meta: {
|
|
658
|
+
chat_id,
|
|
659
|
+
tg_channel: this.channel.name,
|
|
660
|
+
in_reply_to: u.update_id,
|
|
661
|
+
streamed: true,
|
|
662
|
+
iteration: ev.iteration,
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
} else if (ev.type === "tool_result" && ev.trace) {
|
|
666
|
+
// Logged for the audit trail / other channels — NOT sent to Telegram.
|
|
667
|
+
const t = ev.trace;
|
|
668
|
+
appendGlobalMessage({
|
|
669
|
+
channel: "telegram",
|
|
670
|
+
direction: "out",
|
|
671
|
+
type: "tool",
|
|
672
|
+
actor_id: t.tool,
|
|
673
|
+
author: "apx",
|
|
674
|
+
body: `${t.tool}(${JSON.stringify(t.args || {}).slice(0, 200)})`,
|
|
675
|
+
meta: {
|
|
676
|
+
chat_id,
|
|
677
|
+
tg_channel: this.channel.name,
|
|
678
|
+
in_reply_to: u.update_id,
|
|
679
|
+
tool: t.tool,
|
|
680
|
+
args: t.args,
|
|
681
|
+
result: t.result,
|
|
682
|
+
iteration: ev.iteration,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
} catch (e) {
|
|
687
|
+
// A failed intermediate send must not abort the whole run.
|
|
688
|
+
this.log(`telegram[${this.channel.name}] stream event failed: ${e.message}`);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
633
692
|
try {
|
|
634
693
|
const sa = await runSuperAgent({
|
|
635
694
|
globalConfig: this.globalConfig,
|
|
@@ -640,15 +699,20 @@ class ChannelPoller {
|
|
|
640
699
|
previousMessages,
|
|
641
700
|
contextNote: `You are replying inside Telegram right now. Telegram channel="${this.channel.name}", author=${author}, chat_id=${chat_id}. Keep the reply plain-text and concise. Previous turns of this chat are included only for local conversational context; re-call tools for facts.`,
|
|
642
701
|
signal: abortCtrl.signal,
|
|
702
|
+
onEvent,
|
|
643
703
|
});
|
|
644
704
|
replyText = sa.text;
|
|
645
705
|
replyAuthor = sa.name;
|
|
646
|
-
saTrace = sa.trace;
|
|
647
706
|
saUsage = sa.usage;
|
|
648
707
|
} catch (e) {
|
|
649
708
|
if (abortCtrl.signal.aborted) {
|
|
709
|
+
// A newer message superseded this one. Whatever streamed so far is
|
|
710
|
+
// already sent + logged; the newer message's run continues the
|
|
711
|
+
// thread from that history.
|
|
650
712
|
this.log(`telegram[${this.channel.name}] request aborted for chat ${chat_id}`);
|
|
651
|
-
|
|
713
|
+
if (chat_id) this.activeRequests.delete(chat_id);
|
|
714
|
+
stopTyping();
|
|
715
|
+
return;
|
|
652
716
|
}
|
|
653
717
|
this.log(`telegram[${this.channel.name}] super-agent failed: ${e.message}`);
|
|
654
718
|
// Surface the failure to the user instead of silently dropping the
|
|
@@ -660,37 +724,29 @@ class ChannelPoller {
|
|
|
660
724
|
}
|
|
661
725
|
|
|
662
726
|
if (chat_id) this.activeRequests.delete(chat_id);
|
|
663
|
-
if (!replyText) {
|
|
664
|
-
stopTyping();
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
727
|
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
|
|
728
|
+
// Final answer. The intermediate prose was already streamed; only send the
|
|
729
|
+
// final text if it's non-empty AND not a duplicate of the last streamed
|
|
730
|
+
// piece (the loop can end on an iteration whose text was already sent).
|
|
731
|
+
// If nothing streamed and there's no final text, send a minimal ack so the
|
|
732
|
+
// turn isn't silently empty.
|
|
733
|
+
const finalClean = replyText ? stripThinking(replyText).trim() : "";
|
|
734
|
+
let toSend = "";
|
|
735
|
+
if (finalClean && finalClean !== lastStreamedText) toSend = finalClean;
|
|
736
|
+
else if (!finalClean && streamedCount === 0) toSend = "Listo.";
|
|
673
737
|
|
|
674
|
-
// Send reply via this channel's bot
|
|
675
738
|
stopTyping();
|
|
739
|
+
if (!toSend) return; // everything was already streamed — nothing left to send
|
|
740
|
+
|
|
676
741
|
try {
|
|
677
|
-
await this._send({ chat_id, text:
|
|
678
|
-
// Log outbound — store the cleaned text (what we actually sent). The
|
|
679
|
-
// full reasoning (if any) goes in meta_json so it's recoverable.
|
|
742
|
+
await this._send({ chat_id, text: toSend });
|
|
680
743
|
const meta = {
|
|
681
744
|
chat_id,
|
|
682
745
|
tg_channel: this.channel.name,
|
|
683
746
|
in_reply_to: u.update_id,
|
|
747
|
+
final: true,
|
|
684
748
|
};
|
|
685
|
-
if (
|
|
686
|
-
if (saTrace && saTrace.length > 0) {
|
|
687
|
-
// Compact representation: [{tool, args}] without the full result
|
|
688
|
-
// (results can be huge — keep them out of the long-lived FS log).
|
|
689
|
-
meta.tools_called = saTrace.map((t) => ({
|
|
690
|
-
tool: t.tool,
|
|
691
|
-
args: t.args,
|
|
692
|
-
}));
|
|
693
|
-
}
|
|
749
|
+
if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
|
|
694
750
|
if (saUsage) meta.usage = saUsage;
|
|
695
751
|
appendGlobalMessage({
|
|
696
752
|
channel: "telegram",
|
|
@@ -699,7 +755,7 @@ class ChannelPoller {
|
|
|
699
755
|
actor_id: replyAuthor || "apx",
|
|
700
756
|
agent_slug: replyAuthor || "apx",
|
|
701
757
|
author: replyAuthor || "apx",
|
|
702
|
-
body:
|
|
758
|
+
body: toSend,
|
|
703
759
|
meta,
|
|
704
760
|
});
|
|
705
761
|
} catch (e) {
|
|
@@ -711,15 +767,12 @@ class ChannelPoller {
|
|
|
711
767
|
actor_id: replyAuthor || "apx",
|
|
712
768
|
agent_slug: replyAuthor || "apx",
|
|
713
769
|
author: replyAuthor || "apx",
|
|
714
|
-
body: `[send_failed] ${
|
|
770
|
+
body: `[send_failed] ${toSend}`,
|
|
715
771
|
meta: {
|
|
716
772
|
chat_id,
|
|
717
773
|
tg_channel: this.channel.name,
|
|
718
774
|
in_reply_to: u.update_id,
|
|
719
775
|
send_error: e.message,
|
|
720
|
-
...(saTrace && saTrace.length > 0
|
|
721
|
-
? { tools_called: saTrace.map((t) => ({ tool: t.tool, args: t.args })) }
|
|
722
|
-
: {}),
|
|
723
776
|
...(saUsage ? { usage: saUsage } : {}),
|
|
724
777
|
},
|
|
725
778
|
});
|
|
@@ -134,7 +134,7 @@ HARD RULES (do not deviate):
|
|
|
134
134
|
15. NO-PENDING RULE: never say "give me a second", "I will do it", or "I will try later" as a final answer. Either call the tool in this same turn or say what blocks you.
|
|
135
135
|
16. IDENTITY RULE: when the user asks you to change your name, call yourself something, or update your personality/language, call set_identity and persist the change. Then confirm with your new name.
|
|
136
136
|
17. ROUTINES RULE: NEVER create a routine in the default project (id=0). Routines MUST be tied to a specific registered project. Before adding a routine, call list_projects to find the correct project id or name. Then pass --project <id|name> to apx routine add. If no project fits, ask the user which project to use. Creating routines in project 0/default mixes unrelated projects' schedules and corrupts state.
|
|
137
|
-
18. **NO BARE ACKS
|
|
137
|
+
18. **NO BARE ACKS**: Empty acknowledgments ("ok", "entendido", "dame un minuto", "voy", "checking", "ya te escucho", "ahora lo reviso") are never a valid message — not as a final answer and not as a standalone update. Don't announce that you're about to do something: just do it and report. The user already sees your progress step by step (each iteration's text is shown as its own message), so every line you produce must carry real content — a result, a finding, or a concrete question.
|
|
138
138
|
19. **CWD RULE**: When the channel context includes a "CWD: <path>" line, that is the user's current working directory. References to "este directorio", "este proyecto", "esta carpeta", "acá", "aquí", "this directory", "this project", "current dir/folder" all mean that exact CWD path. Use it as the path argument directly — DO NOT ask the user "what's the path?" when CWD is already given. Example: if user says "agregá este proyecto a la lista", call add_project({path: <CWD>}) immediately.
|
|
139
139
|
20. **NO MANUAL SCAFFOLDING**: To register or scaffold a project, ALWAYS use add_project — it auto-creates AGENTS.md and .apc/project.json when missing (one call, atomic). NEVER write AGENTS.md, .apc/project.json, or any APC scaffold file by hand via run_shell / write_file / shell pipes. The schema must come from the official initApf scaffold, not improvised. If add_project errors, report the error to the user — don't try to work around it with shell hacks. Same for any other APC-managed file (.apc/agents/*, .apc/skills/*, etc.) — use the dedicated tool, never raw filesystem writes.
|
|
140
140
|
21. **SKILLS — ON DEMAND**: The "# Available skills" section below lists every skill available to you (slug + description, NO body). When the user asks about specific APX/APC commands, project structure, agent runtimes, or anything where exact syntax or detailed behavior matches a skill description (in ANY language — match semantically, not by keyword), call load_skill({slug}) to fetch the full markdown body. If a CWD is in the contextNote, pass it as project_path so project-scoped skills resolve. If the user explicitly asks "what skills do you have?", you can either read the catalog below directly OR call list_skills to get a fresh enumeration. Do NOT load skills for trivial / unrelated questions — that wastes tokens. Don't guess CLI syntax when a skill can tell you; load it.
|
|
@@ -2,17 +2,19 @@ export type LogoShape = { left: string[]; right: string[] }
|
|
|
2
2
|
|
|
3
3
|
export const logo: LogoShape = {
|
|
4
4
|
left: [
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
5
|
+
" ",
|
|
6
|
+
"█▀▀█ █▀▀█ █▄▄█",
|
|
7
|
+
"█^^█ █__█ _██_",
|
|
8
|
+
"█__█ █▀▀▀ █▀▀█",
|
|
9
9
|
],
|
|
10
10
|
right: [
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
11
|
+
" ▄ ",
|
|
12
|
+
"█▀▀▀ █▀▀█ █▀▀█ █▀▀█",
|
|
13
|
+
"█___ █__█ █__█ █^^^",
|
|
14
|
+
"▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀",
|
|
15
15
|
],
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const go = logo
|
|
19
|
+
|
|
20
|
+
export const marks = "_^~,"
|
|
@@ -96,8 +96,8 @@ export const cmd: any = {}
|
|
|
96
96
|
export const withTimeout: any = (fn: any) => fn
|
|
97
97
|
export const withNetworkOptions: any = () => {}
|
|
98
98
|
export const resolveNetworkOptionsNoConfig: any = () => ({})
|
|
99
|
-
export const displayCharAt: any = () =>
|
|
100
|
-
export const mentionTriggerIndex: any = () =>
|
|
99
|
+
export const displayCharAt: any = () => undefined
|
|
100
|
+
export const mentionTriggerIndex: any = () => undefined
|
|
101
101
|
export const installPlugin: any = () => {}
|
|
102
102
|
export const patchPluginConfig: any = () => {}
|
|
103
103
|
export const readPluginManifest: any = () => ({})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shim for opencode's `@/cli/cmd/prompt-display`.
|
|
2
|
+
// Ported verbatim from opencode so the prompt autocomplete behaves correctly:
|
|
3
|
+
// the catch-all shim used to stub `mentionTriggerIndex` as `() => -1`, which is
|
|
4
|
+
// `!== undefined` and therefore opened the "@" autocomplete on every keystroke,
|
|
5
|
+
// swallowing Enter / Tab / Ctrl+P.
|
|
6
|
+
|
|
7
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
8
|
+
|
|
9
|
+
function displayOffsetIndex(value: string, offset: number) {
|
|
10
|
+
if (offset <= 0) return 0
|
|
11
|
+
|
|
12
|
+
let width = 0
|
|
13
|
+
for (const part of graphemes.segment(value)) {
|
|
14
|
+
const next = width + Bun.stringWidth(part.segment)
|
|
15
|
+
if (next > offset) return part.index
|
|
16
|
+
width = next
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value.length
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
|
|
23
|
+
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function displayCharAt(value: string, offset: number) {
|
|
27
|
+
let width = 0
|
|
28
|
+
for (const part of graphemes.segment(value)) {
|
|
29
|
+
const next = width + Bun.stringWidth(part.segment)
|
|
30
|
+
if (offset === width || offset < next) return part.segment
|
|
31
|
+
width = next
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
|
|
36
|
+
const text = displaySlice(value, 0, offset)
|
|
37
|
+
const index = text.lastIndexOf("@")
|
|
38
|
+
if (index === -1) return
|
|
39
|
+
|
|
40
|
+
const before = index === 0 ? undefined : text[index - 1]
|
|
41
|
+
const query = text.slice(index)
|
|
42
|
+
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
|
43
|
+
return Bun.stringWidth(text.slice(0, index))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,10 +1,104 @@
|
|
|
1
|
+
// Shim for opencode's `@/util/locale`.
|
|
2
|
+
// Ported verbatim from opencode — the previous stub was missing `truncate`,
|
|
3
|
+
// `truncateLeft`, `time`, `datetime`, `duration`, etc., which crashed the
|
|
4
|
+
// command palette dialog (`Locale.truncate is not a function`).
|
|
5
|
+
|
|
6
|
+
function titlecase(str: string) {
|
|
7
|
+
return str.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function time(input: number): string {
|
|
11
|
+
const date = new Date(input)
|
|
12
|
+
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function datetime(input: number): string {
|
|
16
|
+
const date = new Date(input)
|
|
17
|
+
const localTime = time(input)
|
|
18
|
+
const localDate = date.toLocaleDateString()
|
|
19
|
+
return `${localTime} · ${localDate}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function todayTimeOrDateTime(input: number): string {
|
|
23
|
+
const date = new Date(input)
|
|
24
|
+
const now = new Date()
|
|
25
|
+
const isToday =
|
|
26
|
+
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
|
27
|
+
|
|
28
|
+
if (isToday) {
|
|
29
|
+
return time(input)
|
|
30
|
+
} else {
|
|
31
|
+
return datetime(input)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function number(num: number): string {
|
|
36
|
+
if (num >= 1000000) {
|
|
37
|
+
return (num / 1000000).toFixed(1) + "M"
|
|
38
|
+
} else if (num >= 1000) {
|
|
39
|
+
return (num / 1000).toFixed(1) + "K"
|
|
40
|
+
}
|
|
41
|
+
return num.toString()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function duration(input: number) {
|
|
45
|
+
if (input < 1000) {
|
|
46
|
+
return `${input}ms`
|
|
47
|
+
}
|
|
48
|
+
if (input < 60000) {
|
|
49
|
+
return `${(input / 1000).toFixed(1)}s`
|
|
50
|
+
}
|
|
51
|
+
if (input < 3600000) {
|
|
52
|
+
const minutes = Math.floor(input / 60000)
|
|
53
|
+
const seconds = Math.floor((input % 60000) / 1000)
|
|
54
|
+
return `${minutes}m ${seconds}s`
|
|
55
|
+
}
|
|
56
|
+
if (input < 86400000) {
|
|
57
|
+
const hours = Math.floor(input / 3600000)
|
|
58
|
+
const minutes = Math.floor((input % 3600000) / 60000)
|
|
59
|
+
return `${hours}h ${minutes}m`
|
|
60
|
+
}
|
|
61
|
+
const hours = Math.floor(input / 3600000)
|
|
62
|
+
const days = Math.floor((input % 3600000) / 86400000)
|
|
63
|
+
return `${days}d ${hours}h`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(str: string, len: number): string {
|
|
67
|
+
if (str.length <= len) return str
|
|
68
|
+
return str.slice(0, len - 1) + "…"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function truncateLeft(str: string, len: number): string {
|
|
72
|
+
if (str.length <= len) return str
|
|
73
|
+
return "…" + str.slice(-(len - 1))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function truncateMiddle(str: string, maxLength: number = 35): string {
|
|
77
|
+
if (str.length <= maxLength) return str
|
|
78
|
+
|
|
79
|
+
const ellipsis = "…"
|
|
80
|
+
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
|
|
81
|
+
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
|
|
82
|
+
|
|
83
|
+
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pluralize(count: number, singular: string, plural: string): string {
|
|
87
|
+
const template = count === 1 ? singular : plural
|
|
88
|
+
return template.replace("{}", count.toString())
|
|
89
|
+
}
|
|
90
|
+
|
|
1
91
|
export const Locale = {
|
|
92
|
+
titlecase,
|
|
93
|
+
time,
|
|
94
|
+
datetime,
|
|
95
|
+
todayTimeOrDateTime,
|
|
96
|
+
number,
|
|
97
|
+
duration,
|
|
98
|
+
truncate,
|
|
99
|
+
truncateLeft,
|
|
100
|
+
truncateMiddle,
|
|
101
|
+
pluralize,
|
|
102
|
+
// legacy alias kept for any callers expecting `format`
|
|
2
103
|
format: (n: number) => String(n),
|
|
3
|
-
number: (n: number) => n.toLocaleString(),
|
|
4
|
-
titlecase: (s: string) => s.charAt(0).toUpperCase() + s.slice(1),
|
|
5
|
-
truncateMiddle: (s: string, maxLen: number): string => {
|
|
6
|
-
if (s.length <= maxLen) return s
|
|
7
|
-
const half = Math.floor((maxLen - 3) / 2)
|
|
8
|
-
return s.slice(0, half) + "..." + s.slice(s.length - half)
|
|
9
|
-
},
|
|
10
104
|
}
|
|
@@ -18,6 +18,7 @@ function readToken(): string {
|
|
|
18
18
|
|
|
19
19
|
export type ApxEvent =
|
|
20
20
|
| { type: "session.created"; sessionID: string }
|
|
21
|
+
| { type: "user"; sessionID: string; text: string }
|
|
21
22
|
| { type: "chunk"; sessionID: string; chunk: string }
|
|
22
23
|
| { type: "final"; sessionID: string; text: string; usage?: { input_tokens: number; output_tokens: number } }
|
|
23
24
|
| { type: "error"; sessionID: string; error: string }
|
|
@@ -54,13 +55,29 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
54
55
|
prompt: string,
|
|
55
56
|
previousMessages: Array<{ role: string; content: string }> = [],
|
|
56
57
|
) {
|
|
58
|
+
// Do NOT send `model` — the super-agent owns its model (configured at the
|
|
59
|
+
// system level in ~/.apx/config.json). Overriding it from the TUI would
|
|
60
|
+
// bypass that single source of truth. `props.model` is kept only for
|
|
61
|
+
// display in the sidebar.
|
|
57
62
|
const res = await fetch(`${props.url}/projects/${props.pid}/super-agent/chat/stream`, {
|
|
58
63
|
method: "POST",
|
|
59
64
|
headers: headers(),
|
|
60
|
-
body: JSON.stringify({ prompt,
|
|
65
|
+
body: JSON.stringify({ prompt, previousMessages }),
|
|
61
66
|
signal: abort.signal,
|
|
62
67
|
})
|
|
63
|
-
if (!res.ok || !res.body)
|
|
68
|
+
if (!res.ok || !res.body) {
|
|
69
|
+
// Surface the daemon's actual error message (e.g. {"error":"project not found"})
|
|
70
|
+
// instead of a bare status code.
|
|
71
|
+
let detail = ""
|
|
72
|
+
try {
|
|
73
|
+
const body = await res.text()
|
|
74
|
+
const parsed = JSON.parse(body)
|
|
75
|
+
detail = parsed?.error ?? body
|
|
76
|
+
} catch {
|
|
77
|
+
/* non-JSON / empty body */
|
|
78
|
+
}
|
|
79
|
+
throw new Error(detail ? `${detail} (HTTP ${res.status})` : `stream error: ${res.status}`)
|
|
80
|
+
}
|
|
64
81
|
const reader = res.body.getReader()
|
|
65
82
|
const dec = new TextDecoder()
|
|
66
83
|
let buf = ""
|
|
@@ -90,20 +107,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
// The APX daemon has no generic "create session" route — a chat turn is
|
|
111
|
+
// streamed directly through /super-agent/chat/stream. The TUI still needs a
|
|
112
|
+
// stable session id to group messages, so we mint one locally.
|
|
93
113
|
async function createSession(): Promise<string> {
|
|
94
|
-
|
|
95
|
-
const res = await fetch(`${props.url}/projects/${props.pid}/sessions`, {
|
|
96
|
-
method: "POST",
|
|
97
|
-
headers: {
|
|
98
|
-
"content-type": "application/json",
|
|
99
|
-
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify({}),
|
|
102
|
-
signal: abort.signal,
|
|
103
|
-
})
|
|
104
|
-
if (!res.ok) throw new Error(`createSession: ${res.status}`)
|
|
105
|
-
const data = await res.json()
|
|
106
|
-
return (data as any).id as string
|
|
114
|
+
return `apx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
function runShell(sessionID: string, command: string, cwd: string = process.cwd()): Promise<{ shellID: string; exitCode: number | null }> {
|
|
@@ -170,7 +178,26 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|
|
170
178
|
delete: async (_opts: any) => ({ data: undefined }),
|
|
171
179
|
fork: async (_opts: any) => ({ data: undefined, error: new Error("not supported") }),
|
|
172
180
|
abort: async (_opts: any) => {},
|
|
173
|
-
prompt
|
|
181
|
+
// Called by the opencode home prompt on submit. Extract the text from
|
|
182
|
+
// the message parts, surface it as a user bubble, then stream the reply.
|
|
183
|
+
prompt: async (opts: any) => {
|
|
184
|
+
const sid: string = opts?.sessionID || (await createSession())
|
|
185
|
+
const text = ((opts?.parts ?? []) as any[])
|
|
186
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
187
|
+
.map((p) => p.text)
|
|
188
|
+
.join("\n")
|
|
189
|
+
.trim()
|
|
190
|
+
if (!text) return { data: undefined }
|
|
191
|
+
emitter.emit("event", { type: "user", sessionID: sid, text })
|
|
192
|
+
void streamChat(sid, text).catch((err) => {
|
|
193
|
+
emitter.emit("event", {
|
|
194
|
+
type: "error",
|
|
195
|
+
sessionID: sid,
|
|
196
|
+
error: err instanceof Error ? err.message : String(err),
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
return { data: { id: sid } }
|
|
200
|
+
},
|
|
174
201
|
shell: async (opts: { sessionID?: string; command?: string; cwd?: string }) => {
|
|
175
202
|
if (!opts?.command) return { data: undefined }
|
|
176
203
|
const sid = opts.sessionID || (await createSession())
|
|
@@ -35,16 +35,45 @@ export const { use: useApxSync, provider: ApxSyncProvider } = createSimpleContex
|
|
|
35
35
|
messages: Record<string, ApxMessage[]>
|
|
36
36
|
currentSessionID: string | undefined
|
|
37
37
|
previousMessages: Array<{ role: string; content: string }>
|
|
38
|
+
usage: { input: number; output: number }
|
|
38
39
|
}>({
|
|
39
40
|
status: "loading",
|
|
40
41
|
sessions: [],
|
|
41
42
|
messages: {},
|
|
42
43
|
currentSessionID: undefined,
|
|
43
44
|
previousMessages: [],
|
|
45
|
+
usage: { input: 0, output: 0 },
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
// Listen to APX stream events
|
|
47
49
|
sdk.event.on("event", (ev: ApxEvent) => {
|
|
50
|
+
if (ev.type === "user") {
|
|
51
|
+
const e = ev
|
|
52
|
+
batch(() => {
|
|
53
|
+
setStore(
|
|
54
|
+
"messages",
|
|
55
|
+
produce((draft) => {
|
|
56
|
+
;(draft[e.sessionID] ??= []).push({
|
|
57
|
+
id: `user-${Date.now()}`,
|
|
58
|
+
sessionID: e.sessionID,
|
|
59
|
+
role: "user",
|
|
60
|
+
text: e.text,
|
|
61
|
+
})
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
setStore("previousMessages", (prev) => [...prev, { role: "user", content: e.text }])
|
|
65
|
+
setStore("currentSessionID", e.sessionID)
|
|
66
|
+
setStore(
|
|
67
|
+
"sessions",
|
|
68
|
+
produce((draft) => {
|
|
69
|
+
if (!draft.some((s) => s.id === e.sessionID)) {
|
|
70
|
+
draft.unshift({ id: e.sessionID, title: e.text.slice(0, 60) || "New session" })
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
if (ev.type === "chunk") {
|
|
49
78
|
const e = ev
|
|
50
79
|
setStore(
|
|
@@ -74,13 +103,30 @@ export const { use: useApxSync, provider: ApxSyncProvider } = createSimpleContex
|
|
|
74
103
|
produce((draft) => {
|
|
75
104
|
const msgs = (draft[e.sessionID] ??= [])
|
|
76
105
|
const last = msgs[msgs.length - 1]
|
|
77
|
-
if (last?.role === "assistant") {
|
|
106
|
+
if (last?.role === "assistant" && last.streaming) {
|
|
107
|
+
// A streaming bubble already exists (chunk events arrived) — finalize it.
|
|
78
108
|
last.text = e.text
|
|
79
109
|
last.streaming = false
|
|
110
|
+
} else {
|
|
111
|
+
// The super-agent delivers the whole reply in `final` with no
|
|
112
|
+
// preceding chunks — create the assistant bubble here.
|
|
113
|
+
msgs.push({
|
|
114
|
+
id: `msg-${Date.now()}`,
|
|
115
|
+
sessionID: e.sessionID,
|
|
116
|
+
role: "assistant",
|
|
117
|
+
text: e.text,
|
|
118
|
+
streaming: false,
|
|
119
|
+
})
|
|
80
120
|
}
|
|
81
121
|
}),
|
|
82
122
|
)
|
|
83
123
|
setStore("previousMessages", (prev) => [...prev, { role: "assistant", content: e.text }])
|
|
124
|
+
if (e.usage) {
|
|
125
|
+
setStore("usage", (u) => ({
|
|
126
|
+
input: u.input + (e.usage?.input_tokens ?? 0),
|
|
127
|
+
output: u.output + (e.usage?.output_tokens ?? 0),
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
84
130
|
}
|
|
85
131
|
|
|
86
132
|
if (ev.type === "shell.start") {
|
package/src/tui/context/sync.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createSimpleContext } from "./helper"
|
|
9
9
|
import { useApxSync } from "./sync-apx"
|
|
10
|
+
import { useArgs } from "./args"
|
|
10
11
|
import { onMount } from "solid-js"
|
|
11
12
|
|
|
12
13
|
// Re-export useApxSync as useSync for compatibility
|
|
@@ -14,25 +15,59 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|
|
14
15
|
name: "Sync",
|
|
15
16
|
init: () => {
|
|
16
17
|
const apx = useApxSync()
|
|
18
|
+
const args = useArgs()
|
|
17
19
|
|
|
18
20
|
onMount(() => {
|
|
19
21
|
// APX sync already loads sessions in its own onMount
|
|
20
22
|
})
|
|
21
23
|
|
|
24
|
+
// APX has a single configured super-agent (passed via --agent). The opencode
|
|
25
|
+
// TUI expects `data.agent` to be a non-empty list, otherwise the prompt has
|
|
26
|
+
// no "current agent" and Enter silently refuses to submit.
|
|
27
|
+
const apxAgent = () => ({
|
|
28
|
+
name: args.agent || "apx",
|
|
29
|
+
mode: "primary" as const,
|
|
30
|
+
hidden: false,
|
|
31
|
+
description: "APX super-agent",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// APX uses freeform model strings and resolves the model server-side from
|
|
35
|
+
// the CLI `--model` arg. The opencode TUI, however, refuses to submit unless
|
|
36
|
+
// `local.model.current()` resolves to a model that exists in some provider.
|
|
37
|
+
// Expose a synthetic provider containing the configured model so model
|
|
38
|
+
// resolution / validation passes. The values are cosmetic — the daemon
|
|
39
|
+
// ignores them and uses the CLI model string directly.
|
|
40
|
+
const apxModelKey = () => args.model || "apx-default"
|
|
41
|
+
const apxProvider = () => ({
|
|
42
|
+
id: "apx",
|
|
43
|
+
name: "APX",
|
|
44
|
+
models: {
|
|
45
|
+
[apxModelKey()]: {
|
|
46
|
+
id: apxModelKey(),
|
|
47
|
+
name: apxModelKey(),
|
|
48
|
+
capabilities: { reasoning: false },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
22
53
|
// Return a compatible object that matches the shape expected by existing TUI components
|
|
23
54
|
return {
|
|
24
55
|
data: {
|
|
25
56
|
get status() {
|
|
26
57
|
return apx.status
|
|
27
58
|
},
|
|
28
|
-
// Provider fields
|
|
29
|
-
provider
|
|
59
|
+
// Provider fields — synthetic single provider wrapping the APX model.
|
|
60
|
+
get provider() {
|
|
61
|
+
return [apxProvider()] as any[]
|
|
62
|
+
},
|
|
30
63
|
provider_default: {} as Record<string, string>,
|
|
31
64
|
provider_next: { all: [], default: {}, connected: [] } as any,
|
|
32
65
|
provider_auth: {} as Record<string, any[]>,
|
|
33
66
|
console_state: { switchableOrgCount: 0 } as any,
|
|
34
|
-
// Agent fields —
|
|
35
|
-
agent
|
|
67
|
+
// Agent fields — APX exposes the single configured super-agent.
|
|
68
|
+
get agent() {
|
|
69
|
+
return [apxAgent()] as any[]
|
|
70
|
+
},
|
|
36
71
|
command: [] as any[],
|
|
37
72
|
// Session-related — delegate to APX
|
|
38
73
|
get session() {
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A self-contained chat interface that streams messages through the APX daemon
|
|
5
5
|
* using the APX sync context. The complex opencode session view is replaced with
|
|
6
|
-
* a simple but functional chat layout.
|
|
6
|
+
* a simple but functional chat layout, plus an APX-tailored sidebar.
|
|
7
7
|
*/
|
|
8
|
-
import { For, Show, createMemo, createSignal, onCleanup
|
|
8
|
+
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
|
|
9
9
|
import { TextareaRenderable } from "@opentui/core"
|
|
10
10
|
import { useTerminalDimensions } from "@opentui/solid"
|
|
11
11
|
import { useTheme } from "@tui/context/theme"
|
|
@@ -15,6 +15,20 @@ import { useToast, Toast } from "@tui/ui/toast"
|
|
|
15
15
|
import { useExit } from "@tui/context/exit"
|
|
16
16
|
import { usePromptRef } from "@tui/context/prompt"
|
|
17
17
|
import type { ApxMessage } from "@tui/context/sync-apx"
|
|
18
|
+
import { SidebarApx } from "./sidebar-apx"
|
|
19
|
+
|
|
20
|
+
/** Split a daemon error like "fetch failed (trace: abc-123)" into message + trace. */
|
|
21
|
+
function parseError(raw: string): { message: string; trace?: string; hint?: string } {
|
|
22
|
+
const m = raw.match(/^([\s\S]*?)\s*\(trace:\s*([^)]+)\)\s*$/)
|
|
23
|
+
const message = (m ? m[1] : raw).trim()
|
|
24
|
+
const trace = m ? m[2].trim() : undefined
|
|
25
|
+
let hint: string | undefined
|
|
26
|
+
if (/fetch failed/i.test(message))
|
|
27
|
+
hint = "No se pudo contactar el modelo. Verificá que el proveedor esté disponible (p. ej. Ollama corriendo)."
|
|
28
|
+
else if (/not enabled/i.test(message)) hint = "El super-agent está deshabilitado en ~/.apx/config.json."
|
|
29
|
+
else if (/\b401\b|unauthorized|api[_ ]?key/i.test(message)) hint = "Revisá la API key del proveedor en ~/.apx/config.json."
|
|
30
|
+
return { message, trace, hint }
|
|
31
|
+
}
|
|
18
32
|
|
|
19
33
|
function UserBubble(props: { msg: ApxMessage }) {
|
|
20
34
|
const { theme } = useTheme()
|
|
@@ -30,18 +44,51 @@ function UserBubble(props: { msg: ApxMessage }) {
|
|
|
30
44
|
)
|
|
31
45
|
}
|
|
32
46
|
|
|
33
|
-
function
|
|
47
|
+
function ErrorBubble(props: { msg: ApxMessage }) {
|
|
34
48
|
const { theme } = useTheme()
|
|
35
|
-
const
|
|
49
|
+
const parsed = createMemo(() => parseError(props.msg.text))
|
|
50
|
+
return (
|
|
51
|
+
<box flexDirection="column" marginBottom={1} paddingLeft={2} paddingRight={2}>
|
|
52
|
+
<text color={theme.error} bold>
|
|
53
|
+
⚠ Error
|
|
54
|
+
</text>
|
|
55
|
+
<text color={theme.error} wrap>
|
|
56
|
+
{parsed().message}
|
|
57
|
+
</text>
|
|
58
|
+
<Show when={parsed().hint}>
|
|
59
|
+
{(hint) => (
|
|
60
|
+
<text color={theme.textMuted} wrap>
|
|
61
|
+
{hint()}
|
|
62
|
+
</text>
|
|
63
|
+
)}
|
|
64
|
+
</Show>
|
|
65
|
+
<Show when={parsed().trace}>
|
|
66
|
+
{(trace) => <text color={theme.textMuted}>trace: {trace()}</text>}
|
|
67
|
+
</Show>
|
|
68
|
+
</box>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function AssistantBubble(props: { msg: ApxMessage }) {
|
|
73
|
+
const { theme, syntax } = useTheme()
|
|
74
|
+
const hasText = () => props.msg.text.trim().length > 0
|
|
36
75
|
return (
|
|
37
76
|
<box flexDirection="column" marginBottom={1} paddingLeft={2} paddingRight={2}>
|
|
38
77
|
<text color={theme.success} bold>
|
|
39
78
|
{props.msg.streaming ? "Assistant ▸" : "Assistant"}
|
|
40
79
|
</text>
|
|
41
80
|
<Show when={hasText()} fallback={<text color={theme.textMuted}>…</text>}>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
<box flexShrink={0}>
|
|
82
|
+
<code
|
|
83
|
+
filetype="markdown"
|
|
84
|
+
drawUnstyledText={false}
|
|
85
|
+
streaming={props.msg.streaming ?? false}
|
|
86
|
+
syntaxStyle={syntax()}
|
|
87
|
+
content={props.msg.text.trim()}
|
|
88
|
+
conceal={true}
|
|
89
|
+
fg={theme.text}
|
|
90
|
+
/>
|
|
91
|
+
</box>
|
|
45
92
|
</Show>
|
|
46
93
|
</box>
|
|
47
94
|
)
|
|
@@ -66,10 +113,7 @@ function ShellBubble(props: { msg: ApxMessage }) {
|
|
|
66
113
|
<text color={theme.warning ?? theme.primary} bold>
|
|
67
114
|
{header()}
|
|
68
115
|
</text>
|
|
69
|
-
<text
|
|
70
|
-
color={props.msg.exitCode && props.msg.exitCode !== 0 ? theme.error : theme.text}
|
|
71
|
-
wrap
|
|
72
|
-
>
|
|
116
|
+
<text color={props.msg.exitCode && props.msg.exitCode !== 0 ? theme.error : theme.text} wrap>
|
|
73
117
|
{body()}
|
|
74
118
|
</text>
|
|
75
119
|
</box>
|
|
@@ -152,68 +196,77 @@ export function Session() {
|
|
|
152
196
|
|
|
153
197
|
return (
|
|
154
198
|
<box flexDirection="column" flexGrow={1} width={dims().width} height={dims().height}>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
flexGrow={1}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<
|
|
199
|
+
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
|
200
|
+
{/* Chat column */}
|
|
201
|
+
<box flexDirection="column" flexGrow={1} minWidth={0}>
|
|
202
|
+
{/* Message list */}
|
|
203
|
+
<scrollbox
|
|
204
|
+
flexGrow={1}
|
|
205
|
+
stickyScroll
|
|
206
|
+
stickyStart="bottom"
|
|
207
|
+
verticalScrollbarOptions={{ visible: true }}
|
|
208
|
+
>
|
|
209
|
+
<box flexDirection="column">
|
|
210
|
+
<Show
|
|
211
|
+
when={messages().length > 0}
|
|
212
|
+
fallback={
|
|
213
|
+
<box paddingLeft={2} paddingTop={2}>
|
|
214
|
+
<text color={theme.textMuted} italic>
|
|
215
|
+
Type a message to chat, or prefix with ! to run a shell command (e.g. !ls).
|
|
216
|
+
</text>
|
|
217
|
+
</box>
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
<For each={messages()}>
|
|
221
|
+
{(msg) => {
|
|
222
|
+
if (msg.role === "user") return <UserBubble msg={msg} />
|
|
223
|
+
if (msg.role === "shell") return <ShellBubble msg={msg} />
|
|
224
|
+
if (msg.error) return <ErrorBubble msg={msg} />
|
|
225
|
+
return <AssistantBubble msg={msg} />
|
|
226
|
+
}}
|
|
227
|
+
</For>
|
|
228
|
+
</Show>
|
|
229
|
+
<box height={1} />
|
|
230
|
+
</box>
|
|
231
|
+
</scrollbox>
|
|
232
|
+
|
|
233
|
+
{/* Input area */}
|
|
234
|
+
<box
|
|
235
|
+
flexShrink={0}
|
|
236
|
+
flexDirection="column"
|
|
237
|
+
borderTop={1}
|
|
238
|
+
borderColor={theme.border}
|
|
239
|
+
backgroundColor={theme.backgroundElement}
|
|
240
|
+
>
|
|
241
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
|
242
|
+
<textarea
|
|
243
|
+
ref={(r: TextareaRenderable) => {
|
|
244
|
+
inputEl = r
|
|
245
|
+
promptRef.set(makeRef(r))
|
|
246
|
+
}}
|
|
247
|
+
placeholder={sending() ? "Waiting for response…" : "Ask anything... (prefix ! to run shell, e.g. !ls)"}
|
|
248
|
+
placeholderColor={theme.textMuted}
|
|
249
|
+
textColor={theme.text}
|
|
250
|
+
focusedTextColor={theme.text}
|
|
251
|
+
minHeight={1}
|
|
252
|
+
maxHeight={6}
|
|
253
|
+
onSubmit={() => {
|
|
254
|
+
setTimeout(() => setTimeout(() => handleSubmit(), 0), 0)
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
</box>
|
|
258
|
+
<box height={1} paddingLeft={2} paddingRight={2} justifyContent="space-between" flexDirection="row">
|
|
259
|
+
<Show when={sending()} fallback={<text color={theme.textMuted}>enter send · ! shell · exit quit</text>}>
|
|
167
260
|
<text color={theme.textMuted} italic>
|
|
168
|
-
|
|
261
|
+
Streaming…
|
|
169
262
|
</text>
|
|
170
|
-
</
|
|
171
|
-
|
|
172
|
-
>
|
|
173
|
-
<For each={messages()}>
|
|
174
|
-
{(msg) => {
|
|
175
|
-
if (msg.role === "user") return <UserBubble msg={msg} />
|
|
176
|
-
if (msg.role === "shell") return <ShellBubble msg={msg} />
|
|
177
|
-
return <AssistantBubble msg={msg} />
|
|
178
|
-
}}
|
|
179
|
-
</For>
|
|
180
|
-
</Show>
|
|
181
|
-
<box height={1} />
|
|
182
|
-
</box>
|
|
183
|
-
</scrollbox>
|
|
184
|
-
|
|
185
|
-
{/* Input area */}
|
|
186
|
-
<box
|
|
187
|
-
flexShrink={0}
|
|
188
|
-
flexDirection="column"
|
|
189
|
-
borderTop={1}
|
|
190
|
-
borderColor={theme.border}
|
|
191
|
-
backgroundColor={theme.backgroundElement}
|
|
192
|
-
>
|
|
193
|
-
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
|
194
|
-
<textarea
|
|
195
|
-
ref={(r: TextareaRenderable) => {
|
|
196
|
-
inputEl = r
|
|
197
|
-
promptRef.set(makeRef(r))
|
|
198
|
-
}}
|
|
199
|
-
placeholder={sending() ? "Waiting for response…" : "Ask anything... (prefix ! to run shell, e.g. !ls)"}
|
|
200
|
-
placeholderColor={theme.textMuted}
|
|
201
|
-
textColor={theme.text}
|
|
202
|
-
focusedTextColor={theme.text}
|
|
203
|
-
minHeight={1}
|
|
204
|
-
maxHeight={6}
|
|
205
|
-
onSubmit={() => {
|
|
206
|
-
setTimeout(() => setTimeout(() => handleSubmit(), 0), 0)
|
|
207
|
-
}}
|
|
208
|
-
/>
|
|
209
|
-
</box>
|
|
210
|
-
<box height={1} paddingLeft={2} paddingRight={2}>
|
|
211
|
-
<Show when={sending()}>
|
|
212
|
-
<text color={theme.textMuted} italic>
|
|
213
|
-
Streaming…
|
|
214
|
-
</text>
|
|
215
|
-
</Show>
|
|
263
|
+
</Show>
|
|
264
|
+
</box>
|
|
265
|
+
</box>
|
|
216
266
|
</box>
|
|
267
|
+
|
|
268
|
+
{/* Sidebar */}
|
|
269
|
+
<SidebarApx sessionID={sessionID()} />
|
|
217
270
|
</box>
|
|
218
271
|
<Toast />
|
|
219
272
|
</box>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APX session sidebar.
|
|
3
|
+
*
|
|
4
|
+
* A self-contained panel tailored to APX data (session, agent, model, token
|
|
5
|
+
* usage, working directory). Replaces opencode's plugin-driven sidebar.tsx,
|
|
6
|
+
* which depends on feature plugins APX does not ship.
|
|
7
|
+
*/
|
|
8
|
+
import { createMemo, Show } from "solid-js"
|
|
9
|
+
import { useTheme } from "@tui/context/theme"
|
|
10
|
+
import { useApxSync } from "@tui/context/sync-apx"
|
|
11
|
+
import { useSDK } from "@tui/context/sdk-apx"
|
|
12
|
+
import pkg from "../../../../package.json"
|
|
13
|
+
|
|
14
|
+
function titlecase(value: string) {
|
|
15
|
+
if (!value) return value
|
|
16
|
+
return value.charAt(0).toUpperCase() + value.slice(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Section(props: { title: string; children: any }) {
|
|
20
|
+
const { theme } = useTheme()
|
|
21
|
+
return (
|
|
22
|
+
<box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
23
|
+
<text fg={theme.text}>
|
|
24
|
+
<b>{props.title}</b>
|
|
25
|
+
</text>
|
|
26
|
+
{props.children}
|
|
27
|
+
</box>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SidebarApx(props: { sessionID: string }) {
|
|
32
|
+
const { theme } = useTheme()
|
|
33
|
+
const sync = useApxSync()
|
|
34
|
+
const sdk = useSDK()
|
|
35
|
+
|
|
36
|
+
const session = createMemo(() => sync.session.get(props.sessionID))
|
|
37
|
+
const messages = createMemo(() => sync.session.messages(props.sessionID))
|
|
38
|
+
const usage = createMemo(() => sync.data.usage)
|
|
39
|
+
const totalTokens = createMemo(() => usage().input + usage().output)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<box
|
|
43
|
+
backgroundColor={theme.backgroundPanel}
|
|
44
|
+
width={40}
|
|
45
|
+
height="100%"
|
|
46
|
+
flexShrink={0}
|
|
47
|
+
flexDirection="column"
|
|
48
|
+
paddingTop={1}
|
|
49
|
+
paddingBottom={1}
|
|
50
|
+
paddingLeft={2}
|
|
51
|
+
paddingRight={2}
|
|
52
|
+
>
|
|
53
|
+
<box flexGrow={1} flexDirection="column">
|
|
54
|
+
<Section title="Sesión">
|
|
55
|
+
<text fg={theme.textMuted}>{session()?.title || "chat local"}</text>
|
|
56
|
+
</Section>
|
|
57
|
+
|
|
58
|
+
<Section title="Agente">
|
|
59
|
+
<text fg={theme.textMuted}>{titlecase(sdk.agent)}</text>
|
|
60
|
+
</Section>
|
|
61
|
+
|
|
62
|
+
<Section title="Modelo">
|
|
63
|
+
<text fg={theme.textMuted} wrap>
|
|
64
|
+
{sdk.model}
|
|
65
|
+
</text>
|
|
66
|
+
</Section>
|
|
67
|
+
|
|
68
|
+
<Section title="Contexto">
|
|
69
|
+
<text fg={theme.textMuted}>{totalTokens().toLocaleString()} tokens</text>
|
|
70
|
+
<text fg={theme.textMuted}>
|
|
71
|
+
{usage().input.toLocaleString()} in · {usage().output.toLocaleString()} out
|
|
72
|
+
</text>
|
|
73
|
+
<text fg={theme.textMuted}>{messages().length} mensajes</text>
|
|
74
|
+
</Section>
|
|
75
|
+
|
|
76
|
+
<Section title="Directorio">
|
|
77
|
+
<text fg={theme.textMuted} wrap>
|
|
78
|
+
{process.cwd()}
|
|
79
|
+
</text>
|
|
80
|
+
</Section>
|
|
81
|
+
</box>
|
|
82
|
+
|
|
83
|
+
<box flexShrink={0} paddingTop={1}>
|
|
84
|
+
<text fg={theme.textMuted}>
|
|
85
|
+
<span style={{ fg: theme.success }}>•</span> <b>APX</b> <span>{pkg.version}</span>
|
|
86
|
+
</text>
|
|
87
|
+
</box>
|
|
88
|
+
</box>
|
|
89
|
+
)
|
|
90
|
+
}
|
package/src/tui/tsconfig.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"@/snapshot": ["./_shims/snapshot.ts"],
|
|
24
24
|
"@/config/console-state": ["./_shims/config-console-state.ts"],
|
|
25
25
|
"@/cli/error": ["./_shims/cli-error.ts"],
|
|
26
|
+
"@/cli/cmd/prompt-display": ["./_shims/prompt-display.ts"],
|
|
26
27
|
"@/cli/logo": ["./_shims/cli-logo.ts"],
|
|
27
28
|
"@/cli/ui.ts": ["./_shims/cli-ui.ts"],
|
|
28
29
|
"@/cli/ui": ["./_shims/cli-ui.ts"],
|