@agentprojectcontext/apx 1.20.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.20.0",
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
- "fuzzysort": "^3.1.0",
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
  },
@@ -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 = () => -1
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, model: props.model, previousMessages }),
65
+ body: JSON.stringify({ prompt, previousMessages }),
61
66
  signal: abort.signal,
62
67
  })
63
- if (!res.ok || !res.body) throw new Error(`stream error: ${res.status}`)
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
- const token = readToken()
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: async (_opts: any) => {},
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") {
@@ -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 (APX has no providers return stubs)
29
- provider: [] as any[],
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 — empty for APX (agent is configured via CLI args)
35
- agent: [] as any[],
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, onMount } from "solid-js"
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 AssistantBubble(props: { msg: ApxMessage }) {
47
+ function ErrorBubble(props: { msg: ApxMessage }) {
34
48
  const { theme } = useTheme()
35
- const hasText = () => props.msg.text.length > 0
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
- <text color={props.msg.error ? theme.error : theme.text} wrap>
43
- {props.msg.text}
44
- </text>
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
- {/* Message list */}
156
- <scrollbox
157
- flexGrow={1}
158
- stickyScroll
159
- stickyStart="bottom"
160
- verticalScrollbarOptions={{ visible: true }}
161
- >
162
- <box flexDirection="column" width={dims().width}>
163
- <Show
164
- when={messages().length > 0}
165
- fallback={
166
- <box paddingLeft={2} paddingTop={2}>
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
- Type a message to chat, or prefix with ! to run a shell command (e.g. !ls).
261
+ Streaming…
169
262
  </text>
170
- </box>
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
+ }
@@ -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"],