@nanhara/hara 0.99.2 → 0.100.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/CHANGELOG.md +41 -0
- package/README.md +1 -1
- package/dist/agent/compact.js +17 -0
- package/dist/agent/loop.js +36 -4
- package/dist/agent/reminders.js +38 -0
- package/dist/index.js +45 -15
- package/dist/org/roles.js +34 -8
- package/dist/tui/App.js +52 -43
- package/dist/tui/InputBox.js +8 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.100.0 — the agent keeps its own attention: system-reminders + anti-drift compaction
|
|
9
|
+
|
|
10
|
+
Distilled from a source-level study of Claude Code v1.0.33's agent internals (the Ie1/WD5 reminder
|
|
11
|
+
layer and the AU2 compaction template).
|
|
12
|
+
|
|
13
|
+
- **system-reminder injection layer.** An event queue (`agent/reminders.ts`) that lands queued context
|
|
14
|
+
as ONE `<system-reminder>`-wrapped message before the next model call — visible to the model, never
|
|
15
|
+
rendered in the transcript, and always carrying the "ignore unless relevant" disclaimer so a nudge
|
|
16
|
+
can't derail unrelated work. Quiet (sub-agent) runs neither drain nor push, so parallel fan-outs
|
|
17
|
+
can't steal the main conversation's reminders. First wired event:
|
|
18
|
+
- **Todo attention-refresh.** When a checklist has unfinished items and goes 5 tool-rounds untouched,
|
|
19
|
+
the model gets a reminder re-showing the authoritative list and asking for a status pass — so long
|
|
20
|
+
tasks stop silently abandoning their own plan. Any `todo_write` resets the clock; after firing it
|
|
21
|
+
re-arms (at most one nag per 5 rounds).
|
|
22
|
+
- **Compaction brief: 6 → 8 sections (anti-drift).** `/compact` and auto-compaction now also preserve
|
|
23
|
+
**All user messages** — your own words survive verbatim, in order, however hard the history is
|
|
24
|
+
squeezed — and **Key technical concepts**, so the next turn doesn't re-derive the stack.
|
|
25
|
+
(`COMPACT_SYSTEM` moved to `agent/compact.ts` where tests pin the structure.)
|
|
26
|
+
|
|
27
|
+
## 0.99.3 — TUI: rock-steady input box (constant-height chrome) + plan mode grows a real handshake
|
|
28
|
+
|
|
29
|
+
Built from a source-level study of codex-rs (bottom-anchored viewport, plan cells) and Claude Code
|
|
30
|
+
(flexShrink-pinned bottom region, ExitPlanMode handshake).
|
|
31
|
+
|
|
32
|
+
- **The input box no longer jumps at turn boundaries.** The bottom chrome is now a CONSTANT-height
|
|
33
|
+
stack: a permanent one-row status slot above the box swaps its content — spinner + verb + queue count
|
|
34
|
+
while working ⇄ dim key hints when idle — instead of appearing/disappearing (the old `Working` block +
|
|
35
|
+
`⌨ working` hint row cost ±3 rows at every turn start/end). The shift+tab picker (`ModeLine`, now ONE
|
|
36
|
+
row with short inline descriptions) swaps into the SAME slot, so popping/auto-hiding it moves nothing.
|
|
37
|
+
The todo panel no longer folds on a 30s timer (which yanked the box up while you read) — it folds to
|
|
38
|
+
its one-line summary when the NEXT turn starts, coinciding with your own submit.
|
|
39
|
+
- `tui/App.tsx`: `StatusRow`/`ModeLine` + the constant slot; fold-on-submit; `tui/InputBox.tsx`: the
|
|
40
|
+
working-hint row and two-row ModeBar are gone (mode still reads colored in the footer).
|
|
41
|
+
- **Plan mode: the MODEL now decides when the plan is ready** (Claude-Code style), instead of hara
|
|
42
|
+
nagging "proceed?" after every read-only turn. A run-scoped `exit_plan` tool (new `extraTools` option
|
|
43
|
+
on `runAgent` — per-run tools, never registered globally) is offered only in plan mode; when the model
|
|
44
|
+
calls it, the plan renders as a bordered `╭─ Plan` block (codex ProposedPlanCell-style) and THEN the
|
|
45
|
+
proceed picker appears. Investigation/Q&A turns end quietly, still in plan mode.
|
|
46
|
+
- `agent/loop.ts`: `opts.extraTools` (advertised post-filter, resolved before the registry);
|
|
47
|
+
`index.ts`: plan branch rewired + `PLAN_SYSTEM` teaches the exit_plan contract.
|
|
48
|
+
|
|
8
49
|
## 0.99.2 — TUI: steadier input box + transient approval selector
|
|
9
50
|
|
|
10
51
|
- **Approval-mode selector is now transient, not always-on chrome.** The persistent two-row ModeBar under every
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
**Highlights**
|
|
14
14
|
- **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
|
|
15
15
|
- **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images** (send a photo → it sees it; ask for a file → it sends one), per-chat resumable sessions, and project roaming. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
|
|
16
|
-
- **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only →
|
|
16
|
+
- **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation → the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
|
|
17
17
|
- **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
|
|
18
18
|
- **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
|
|
19
19
|
- **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. Gated by approval, trust-tiered, and never exposed to read-only sub-agents.
|
package/dist/agent/compact.js
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
// which reuses the manual /compact path; this just decides *when* to fire.
|
|
4
4
|
/** Auto-compact once the last turn used ≥ this % of the model's context window. */
|
|
5
5
|
export const AUTO_COMPACT_PCT = 85;
|
|
6
|
+
/** The compaction brief (shared by /compact and auto-compaction). Eight sections, mirroring Claude
|
|
7
|
+
* Code's AU2 template — the two that matter most beyond the obvious: **All user messages** (the
|
|
8
|
+
* user's own words survive verbatim, so however hard the history is squeezed, intent never drifts)
|
|
9
|
+
* and **Key technical concepts** (the frameworks/APIs in play, so the next turn doesn't re-derive
|
|
10
|
+
* the stack). Lives here (not index.ts) so tests can pin the structure. */
|
|
11
|
+
export const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
|
|
12
|
+
"loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
|
|
13
|
+
"then output ONLY the summary under these exact headings:\n" +
|
|
14
|
+
"1. Goal — the user's overall intent, in their own framing.\n" +
|
|
15
|
+
"2. Key technical concepts — the frameworks, tools, APIs, and domain ideas central to the work.\n" +
|
|
16
|
+
"3. Key decisions — choices made and why (so they aren't relitigated).\n" +
|
|
17
|
+
"4. Files & code — files created/changed and the important snippets, with why each matters.\n" +
|
|
18
|
+
"5. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
|
|
19
|
+
"6. Current state — what works now / what is verified.\n" +
|
|
20
|
+
"7. All user messages — EVERY user message so far (excluding tool results), verbatim and in order; abbreviate only huge pasted blobs with […]. These are the ground truth of intent.\n" +
|
|
21
|
+
"8. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
|
|
22
|
+
"Be specific and concrete. Drop the <analysis>; output only the headed summary.";
|
|
6
23
|
/** Whether to auto-compact now: enabled, the history is substantial enough to be worth summarizing, and the
|
|
7
24
|
* last turn filled the context past the threshold (so the NEXT turn would risk overflow). */
|
|
8
25
|
export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
|
package/dist/agent/loop.js
CHANGED
|
@@ -10,7 +10,8 @@ import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
|
10
10
|
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
11
11
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
12
12
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
13
|
-
import { currentTodos } from "../tools/todo.js";
|
|
13
|
+
import { currentTodos, renderTodos } from "../tools/todo.js";
|
|
14
|
+
import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS } from "./reminders.js";
|
|
14
15
|
/** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
|
|
15
16
|
* surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
|
|
16
17
|
* instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
|
|
@@ -86,6 +87,9 @@ export async function runAgent(history, opts) {
|
|
|
86
87
|
const guardianOn = !!opts.guardian && (opts.guardian.enabled ?? true) && guardianEnabled();
|
|
87
88
|
const breaker = newBreaker();
|
|
88
89
|
let breakerHalt = false; // set when a tripped breaker aborts this run
|
|
90
|
+
// Todo attention-refresh (à la Claude Code): tool rounds since the checklist was last touched while
|
|
91
|
+
// unfinished items exist. Main loop only — quiet (sub-agent) runs share the global list and must not nag.
|
|
92
|
+
let todoIdleRounds = 0;
|
|
89
93
|
for (;;) {
|
|
90
94
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
91
95
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
@@ -93,7 +97,18 @@ export async function runAgent(history, opts) {
|
|
|
93
97
|
for (const m of await opts.pendingInput())
|
|
94
98
|
history.push(m);
|
|
95
99
|
}
|
|
96
|
-
|
|
100
|
+
// system-reminder injection: event-driven context queued since the last call (todo staleness today)
|
|
101
|
+
// lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
|
|
102
|
+
// sub-agent must not steal the main conversation's reminders.
|
|
103
|
+
if (!opts.quiet) {
|
|
104
|
+
const reminders = drainReminders();
|
|
105
|
+
if (reminders.length)
|
|
106
|
+
history.push({ role: "user", content: wrapReminders(reminders) });
|
|
107
|
+
}
|
|
108
|
+
const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
|
|
109
|
+
const specs = opts.extraTools?.length
|
|
110
|
+
? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
|
|
111
|
+
: baseSpecs;
|
|
97
112
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
98
113
|
const tty = stdout.isTTY && !opts.quiet && !sink;
|
|
99
114
|
const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
|
|
@@ -211,14 +226,16 @@ export async function runAgent(history, opts) {
|
|
|
211
226
|
if (r.stop !== "tool_use")
|
|
212
227
|
return;
|
|
213
228
|
const plans = [];
|
|
229
|
+
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
230
|
+
const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
|
|
214
231
|
for (const tu of r.toolUses) {
|
|
215
232
|
if (breakerHalt) {
|
|
216
233
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
217
234
|
// (no hang, no further tools) so the model + user get a definitive stop.
|
|
218
|
-
plans.push({ tu, tool:
|
|
235
|
+
plans.push({ tu, tool: resolveTool(tu.name), denied: "Guardian circuit-breaker halted this run (too many high-risk actions blocked). Ask the user to review and re-run." });
|
|
219
236
|
continue;
|
|
220
237
|
}
|
|
221
|
-
const tool =
|
|
238
|
+
const tool = resolveTool(tu.name);
|
|
222
239
|
if (!tool) {
|
|
223
240
|
plans.push({ tu, tool: undefined, denied: `Unknown tool: ${tu.name}` });
|
|
224
241
|
continue;
|
|
@@ -344,6 +361,21 @@ export async function runAgent(history, opts) {
|
|
|
344
361
|
}
|
|
345
362
|
await flush();
|
|
346
363
|
history.push({ role: "tool", results });
|
|
364
|
+
// Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
|
|
365
|
+
// unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
|
|
366
|
+
// re-showing the authoritative list (then the counter re-arms — at most one nag per N rounds).
|
|
367
|
+
if (!opts.quiet) {
|
|
368
|
+
if (r.toolUses.some((tu) => tu.name === "todo_write")) {
|
|
369
|
+
todoIdleRounds = 0;
|
|
370
|
+
}
|
|
371
|
+
else if (currentTodos().some((t) => t.status !== "done")) {
|
|
372
|
+
todoIdleRounds++;
|
|
373
|
+
if (todoIdleRounds >= TODO_STALE_ROUNDS) {
|
|
374
|
+
pushReminder(todoStaleReminder(renderTodos(currentTodos())));
|
|
375
|
+
todoIdleRounds = 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
347
379
|
if (breakerHalt) {
|
|
348
380
|
// A tripped-and-declined circuit-breaker is a hard stop: end the run cleanly (the denial messages are
|
|
349
381
|
// already in `results` so the model/user see why). Never spin further.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// system-reminder injection (à la Claude Code's Ie1/WD5 event layer): event-driven context the model
|
|
2
|
+
// should see on its NEXT call, injected as ONE `<system-reminder>`-wrapped user message. It rides the
|
|
3
|
+
// provider history only — the UI transcript never renders it, so the user isn't bothered while the
|
|
4
|
+
// model stays synchronized with system state (todo staleness today; file-change/diagnostic events can
|
|
5
|
+
// plug in later via pushReminder).
|
|
6
|
+
//
|
|
7
|
+
// Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
|
|
8
|
+
// injected nudge never derails an unrelated task.
|
|
9
|
+
const queue = [];
|
|
10
|
+
/** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
|
|
11
|
+
* neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
|
|
12
|
+
export function pushReminder(text) {
|
|
13
|
+
const t = text.trim();
|
|
14
|
+
if (t)
|
|
15
|
+
queue.push(t);
|
|
16
|
+
}
|
|
17
|
+
/** Take everything queued (FIFO), clearing the queue. */
|
|
18
|
+
export function drainReminders() {
|
|
19
|
+
if (!queue.length)
|
|
20
|
+
return [];
|
|
21
|
+
return queue.splice(0, queue.length);
|
|
22
|
+
}
|
|
23
|
+
/** Merge queued reminders into the single injected message. */
|
|
24
|
+
export function wrapReminders(items) {
|
|
25
|
+
return ("<system-reminder>\n" +
|
|
26
|
+
items.join("\n\n") +
|
|
27
|
+
"\n\nThis context may or may not be relevant to your task — do not respond to it directly; ignore it unless it is relevant.\n" +
|
|
28
|
+
"</system-reminder>");
|
|
29
|
+
}
|
|
30
|
+
/** How many tool rounds a checklist may sit untouched (with unfinished items) before the model gets an
|
|
31
|
+
* attention refresh. Reset on every todo_write; re-arms after firing so it nags at most once per N. */
|
|
32
|
+
export const TODO_STALE_ROUNDS = 5;
|
|
33
|
+
/** The staleness nudge: re-show the authoritative list + ask for a status pass. */
|
|
34
|
+
export function todoStaleReminder(renderedTodos) {
|
|
35
|
+
return (`Your todo list has not been updated in a while. Current state:\n\n${renderedTodos}\n\n` +
|
|
36
|
+
"If you have completed or started items, update them with todo_write now (statuses drive the user's progress view). " +
|
|
37
|
+
"If the list no longer matches the work, rewrite it.");
|
|
38
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
|
|
|
24
24
|
import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
|
|
25
25
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
26
26
|
import { routingProvider } from "./agent/route.js";
|
|
27
|
-
import { shouldAutoCompact } from "./agent/compact.js";
|
|
27
|
+
import { shouldAutoCompact, COMPACT_SYSTEM } from "./agent/compact.js";
|
|
28
28
|
import { formatContextReport } from "./agent/context-report.js";
|
|
29
29
|
import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
|
|
30
30
|
import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
|
|
@@ -691,9 +691,24 @@ async function nameSession(provider, history) {
|
|
|
691
691
|
return titleFrom(history);
|
|
692
692
|
}
|
|
693
693
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
694
|
+
/** Render a proposed plan as a bordered block for the transcript (codex ProposedPlanCell-style).
|
|
695
|
+
* Left-border-only frame — right-edge alignment against variable-width content is brittle, and the
|
|
696
|
+
* open right side lets long lines wrap naturally. Emitted via the diff sink channel (renders verbatim,
|
|
697
|
+
* not dimmed like notice). */
|
|
698
|
+
const renderPlanBlock = (plan) => {
|
|
699
|
+
const lines = plan.replace(/\n+$/, "").split("\n");
|
|
700
|
+
const top = c.cyan("╭─ ") + c.bold(c.cyan("Plan")) + c.cyan(" " + "─".repeat(42));
|
|
701
|
+
const body = lines.map((l) => c.cyan("│ ") + l).join("\n");
|
|
702
|
+
return `${top}\n${body}\n${c.cyan("╰" + "─".repeat(48))}`;
|
|
703
|
+
};
|
|
704
|
+
// Plan mode's contract (Claude-Code style handshake): the MODEL decides when the plan is ready by
|
|
705
|
+
// calling `exit_plan` — we never nag with a proceed-prompt after turns that were just investigation
|
|
706
|
+
// or Q&A. codex's equivalent is the plan streaming to a dedicated cell + Enter-to-implement.
|
|
707
|
+
const PLAN_SYSTEM = "You are in PLAN MODE — a read-only investigation phase. Explore with read_file / grep / glob / ls / " +
|
|
708
|
+
"web tools and think. Do NOT edit files or run mutating commands — only investigate and plan. " +
|
|
709
|
+
"When (and only when) you have a complete, actionable plan, call the `exit_plan` tool with the full plan " +
|
|
710
|
+
"(concise markdown, short numbered steps), then stop and wait for the user's decision. " +
|
|
711
|
+
"If the user is asking a question, or the plan isn't ready yet, just answer normally WITHOUT calling exit_plan.";
|
|
697
712
|
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
698
713
|
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
699
714
|
const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
|
|
@@ -701,16 +716,7 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
701
716
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
702
717
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
703
718
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
704
|
-
|
|
705
|
-
"loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
|
|
706
|
-
"then output ONLY the summary under these exact headings:\n" +
|
|
707
|
-
"1. Goal — the user's overall intent, in their own framing.\n" +
|
|
708
|
-
"2. Key decisions — choices made and why (so they aren't relitigated).\n" +
|
|
709
|
-
"3. Files & code — files created/changed and the important snippets, with why each matters.\n" +
|
|
710
|
-
"4. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
|
|
711
|
-
"5. Current state — what works now / what is verified.\n" +
|
|
712
|
-
"6. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
|
|
713
|
-
"Be specific and concrete. Drop the <analysis>; output only the headed summary.";
|
|
719
|
+
// COMPACT_SYSTEM (the 8-section brief) lives in agent/compact.ts so tests can pin its structure.
|
|
714
720
|
const workingSetFromSummary = (s) => s
|
|
715
721
|
.split("\n")
|
|
716
722
|
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
@@ -3029,7 +3035,9 @@ program.action(async (opts) => {
|
|
|
3029
3035
|
};
|
|
3030
3036
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
3031
3037
|
if (appr === "plan") {
|
|
3032
|
-
// PLAN MODE: read-only investigate
|
|
3038
|
+
// PLAN MODE: read-only investigate; the MODEL signals plan-readiness by calling `exit_plan`
|
|
3039
|
+
// (Claude-Code style handshake) — only then do we pop the proceed prompt. Turns that were just
|
|
3040
|
+
// investigation / Q&A end back at the input, still in plan mode, with no nagging.
|
|
3033
3041
|
const planImg = await resolveImages(images, h);
|
|
3034
3042
|
if (planImg.skip)
|
|
3035
3043
|
return;
|
|
@@ -3037,12 +3045,29 @@ program.action(async (opts) => {
|
|
|
3037
3045
|
recalledContext = "";
|
|
3038
3046
|
const pin = stats.input;
|
|
3039
3047
|
const pout = stats.output;
|
|
3048
|
+
// Run-scoped tool (never in the registry, so no other mode can see it): captures the proposed
|
|
3049
|
+
// plan and renders it as a bordered block (codex's ProposedPlanCell equivalent) via the sink.
|
|
3050
|
+
let proposedPlan = null;
|
|
3051
|
+
const exitPlanTool = {
|
|
3052
|
+
name: "exit_plan",
|
|
3053
|
+
description: "Call when your plan is complete and ready for the user to approve. Pass the FULL plan as concise " +
|
|
3054
|
+
"markdown (short numbered steps). This ends the planning phase — after calling it, stop and wait.",
|
|
3055
|
+
input_schema: { type: "object", properties: { plan: { type: "string", description: "the complete plan (markdown, short numbered steps)" } }, required: ["plan"] },
|
|
3056
|
+
kind: "read", // never prompts — submitting a plan is not a mutation
|
|
3057
|
+
run: async (input, tctx) => {
|
|
3058
|
+
proposedPlan = String(input?.plan ?? "").trim();
|
|
3059
|
+
if (proposedPlan)
|
|
3060
|
+
tctx.ui?.diff(renderPlanBlock(proposedPlan));
|
|
3061
|
+
return "Plan submitted to the user for approval. Stop now and wait for their decision — do not keep working.";
|
|
3062
|
+
},
|
|
3063
|
+
};
|
|
3040
3064
|
await runAgent(history, {
|
|
3041
3065
|
provider,
|
|
3042
3066
|
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3043
3067
|
approval: "suggest",
|
|
3044
3068
|
confirm: h.confirm,
|
|
3045
3069
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
3070
|
+
extraTools: [exitPlanTool],
|
|
3046
3071
|
systemOverride: PLAN_SYSTEM,
|
|
3047
3072
|
memory: buildMemory(),
|
|
3048
3073
|
projectContext,
|
|
@@ -3056,6 +3081,11 @@ program.action(async (opts) => {
|
|
|
3056
3081
|
}
|
|
3057
3082
|
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
3058
3083
|
saveSession(meta, history);
|
|
3084
|
+
if (!proposedPlan) {
|
|
3085
|
+
// No exit_plan this turn — the model was investigating or answering. Stay in plan mode quietly.
|
|
3086
|
+
notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3059
3089
|
const choice = await h.select("hara has a plan — proceed?", [
|
|
3060
3090
|
{ label: "Yes, and auto-apply edits", value: "auto-edit" },
|
|
3061
3091
|
{ label: "Yes, approve each edit", value: "suggest" },
|
package/dist/org/roles.js
CHANGED
|
@@ -22,13 +22,37 @@ export function orgRolesDir() {
|
|
|
22
22
|
export function claudeAgentsDir(cwd) {
|
|
23
23
|
return join(findProjectRoot(cwd), ".claude", "agents");
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
/** Claude-Code tool names → hara tool names, for `.claude/agents` interop. Without this, a CC agent
|
|
26
|
+
* with `tools: Read, Edit, Bash` produced allowTools that matched ZERO hara tools — the role spawned
|
|
27
|
+
* with an empty toolbox. Unknown names pass through verbatim (they may be hara names already). */
|
|
28
|
+
const CLAUDE_TOOL_MAP = {
|
|
29
|
+
read: "read_file",
|
|
30
|
+
edit: "edit_file",
|
|
31
|
+
write: "write_file",
|
|
32
|
+
bash: "bash",
|
|
33
|
+
grep: "grep",
|
|
34
|
+
glob: "glob",
|
|
35
|
+
ls: "ls",
|
|
36
|
+
webfetch: "web_fetch",
|
|
37
|
+
websearch: "web_search",
|
|
38
|
+
agent: "agent",
|
|
39
|
+
task: "agent",
|
|
40
|
+
todowrite: "todo_write",
|
|
41
|
+
notebookedit: "edit_file",
|
|
42
|
+
};
|
|
43
|
+
/** Accept Claude-Code `tools:` (comma string or list) as an alias for hara's allowTools —
|
|
44
|
+
* translating CC tool names to hara's. "All tools" / "*" means unrestricted → undefined. */
|
|
45
|
+
export function claudeTools(v) {
|
|
46
|
+
const raw = Array.isArray(v)
|
|
47
|
+
? v
|
|
48
|
+
: typeof v === "string" && v.trim()
|
|
49
|
+
? v.split(",").map((s) => s.trim()).filter(Boolean)
|
|
50
|
+
: null;
|
|
51
|
+
if (!raw || !raw.length)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (raw.some((t) => /^(\*|all tools?)$/i.test(t)))
|
|
54
|
+
return undefined; // unrestricted
|
|
55
|
+
return raw.map((t) => CLAUDE_TOOL_MAP[t.toLowerCase().replace(/[^a-z]/g, "")] ?? t);
|
|
32
56
|
}
|
|
33
57
|
function parseFrontmatter(text) {
|
|
34
58
|
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
|
|
@@ -83,7 +107,9 @@ export function loadRoles(cwd) {
|
|
|
83
107
|
description: fm.description || "",
|
|
84
108
|
owns: Array.isArray(fm.owns) ? fm.owns : [],
|
|
85
109
|
rejects: Array.isArray(fm.rejects) ? fm.rejects : [],
|
|
86
|
-
model
|
|
110
|
+
// Claude-Code model ALIASES (sonnet/opus/haiku/inherit) aren't hara model ids — treat as
|
|
111
|
+
// "inherit the session model" rather than passing a string no provider resolves.
|
|
112
|
+
model: fm.model && !/^(sonnet|opus|haiku|inherit)$/i.test(String(fm.model)) ? fm.model : undefined,
|
|
87
113
|
allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
|
|
88
114
|
denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
|
|
89
115
|
system: body,
|
package/dist/tui/App.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The hara TUI (ink). Layout, top to bottom:
|
|
3
|
-
// <Static>
|
|
4
|
-
// current
|
|
5
|
-
// <
|
|
6
|
-
//
|
|
3
|
+
// <Static> committed transcript — rendered once each, scrolls into native scrollback
|
|
4
|
+
// current the in-progress turn's blocks (assistant text / reasoning / tool / diff), live
|
|
5
|
+
// <TodoPanel> live checklist (when the agent keeps one)
|
|
6
|
+
// status slot ALWAYS one row: StatusRow (spinner while working / key hints idle) ⇄ ModeLine
|
|
7
|
+
// (shift+tab picker) — constant height so the input box never bobs at turn boundaries
|
|
8
|
+
// <InputBox> the bordered prompt (or a confirm prompt when a tool needs approval)
|
|
7
9
|
//
|
|
8
10
|
// The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
|
|
9
11
|
// ink-testing-library against a fake runner — no provider/network needed.
|
|
10
12
|
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
11
13
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
-
import { InputBox } from "./InputBox.js";
|
|
14
|
+
import { InputBox, MODES, approvalColor } from "./InputBox.js";
|
|
13
15
|
import { activity } from "../activity.js";
|
|
14
16
|
import { ctxPctFor } from "../statusbar.js";
|
|
15
17
|
import { accent } from "./theme.js";
|
|
16
18
|
import { renderMarkdown } from "../md.js";
|
|
17
|
-
import { currentTodos, onTodosChange } from "../tools/todo.js";
|
|
19
|
+
import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
|
|
18
20
|
let _id = 0;
|
|
19
21
|
const nid = () => ++_id;
|
|
20
22
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -218,25 +220,50 @@ export function spinnerVerb(list, elapsedSec) {
|
|
|
218
220
|
}
|
|
219
221
|
return `working ${elapsedSec}s · esc to interrupt`;
|
|
220
222
|
}
|
|
223
|
+
// The status row — ALWAYS rendered, exactly one content row (codex-style: the bottom pane keeps a
|
|
224
|
+
// constant-height status slot and swaps its CONTENT). This is the anti-bob keystone: the old
|
|
225
|
+
// `Working` block appeared at turn start and vanished at turn end (±2 rows), so the input box
|
|
226
|
+
// jumped up 3 rows at every turn boundary (together with the ⌨-hint line, now folded in here).
|
|
227
|
+
// Working → spinner + verb + queue count; idle → dim key hints. Same height either way → zero shift.
|
|
228
|
+
//
|
|
221
229
|
// The spinner is the only element that animates continuously while a turn runs — and because ink
|
|
222
|
-
// redraws the WHOLE dynamic region (input box
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// stable (it only changes once per second, not coupled to the glyph frame).
|
|
230
|
+
// redraws the WHOLE dynamic region (input box included) on any change, its tick rate sets a floor on
|
|
231
|
+
// full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still reads as
|
|
232
|
+
// smooth motion — meaningfully calmer over a slow/remote link. Elapsed seconds come from a
|
|
233
|
+
// wall-clock start, so the "Ns" text stays exact and stable.
|
|
227
234
|
const SPINNER_FRAME_MS = 125;
|
|
228
|
-
|
|
235
|
+
const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
|
|
236
|
+
function StatusRow({ working, todos, queued }) {
|
|
229
237
|
const [frame, setFrame] = useState(0);
|
|
230
238
|
const startRef = useRef(Date.now());
|
|
231
239
|
useEffect(() => {
|
|
240
|
+
if (!working)
|
|
241
|
+
return;
|
|
232
242
|
startRef.current = Date.now();
|
|
233
243
|
const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
|
|
234
244
|
return () => clearInterval(id);
|
|
235
|
-
}, []);
|
|
245
|
+
}, [working]);
|
|
246
|
+
if (!working) {
|
|
247
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}` }) }));
|
|
248
|
+
}
|
|
236
249
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
237
250
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
238
|
-
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)}` })] }));
|
|
251
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
|
|
239
252
|
}
|
|
253
|
+
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|
|
254
|
+
// don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
|
|
255
|
+
const MODE_HINT = {
|
|
256
|
+
suggest: "confirms edits+cmds",
|
|
257
|
+
"auto-edit": "auto edits · asks cmds",
|
|
258
|
+
"full-auto": "no prompts ⚠",
|
|
259
|
+
plan: "read-only → plan",
|
|
260
|
+
};
|
|
261
|
+
// Transient approval-mode line: popped by shift+tab in PLACE of the StatusRow (equal-height swap —
|
|
262
|
+
// one row for one row, so the picker appearing/auto-hiding never moves the input box). All modes
|
|
263
|
+
// listed, the active one colored, with the active mode's short description inline.
|
|
264
|
+
const ModeLine = memo(function ModeLine({ approval }) {
|
|
265
|
+
return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: approvalColor(m), bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))), _jsx(Text, { dimColor: true, children: ` · ${MODE_HINT[approval]} · ⇄ shift+tab` })] }) }));
|
|
266
|
+
});
|
|
240
267
|
// Live task panel: renders the current todo_write checklist between the in-progress turn output
|
|
241
268
|
// and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
|
|
242
269
|
// `… +N pending/done`. Hidden when the list is empty.
|
|
@@ -290,10 +317,6 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
290
317
|
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
291
318
|
// in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
|
|
292
319
|
const [todos, setTodos] = useState(() => currentTodos());
|
|
293
|
-
// Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
|
|
294
|
-
// final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
|
|
295
|
-
// a new turn starts before the timer fires.
|
|
296
|
-
const collapseTimerRef = useRef(null);
|
|
297
320
|
const ctrlRef = useRef(null);
|
|
298
321
|
const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
|
|
299
322
|
const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
|
|
@@ -323,17 +346,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
323
346
|
useEffect(() => {
|
|
324
347
|
const unsub = onTodosChange((list) => {
|
|
325
348
|
setTodos([...list]); // copy so React sees a new array (the tool reuses one)
|
|
326
|
-
// A change mid-turn cancels any pending collapse — the user is still working with this list.
|
|
327
|
-
if (collapseTimerRef.current) {
|
|
328
|
-
clearTimeout(collapseTimerRef.current);
|
|
329
|
-
collapseTimerRef.current = null;
|
|
330
|
-
}
|
|
331
349
|
});
|
|
332
|
-
return
|
|
333
|
-
unsub();
|
|
334
|
-
if (collapseTimerRef.current)
|
|
335
|
-
clearTimeout(collapseTimerRef.current);
|
|
336
|
-
};
|
|
350
|
+
return unsub;
|
|
337
351
|
}, []);
|
|
338
352
|
// Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
|
|
339
353
|
// append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
|
|
@@ -418,6 +432,15 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
418
432
|
setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
|
|
419
433
|
return;
|
|
420
434
|
}
|
|
435
|
+
// Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
|
|
436
|
+
// 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
|
|
437
|
+
// (anti-bob); folding on submit means the shrink coincides with the user's own action.
|
|
438
|
+
if (currentTodos().length) {
|
|
439
|
+
const list = currentTodos();
|
|
440
|
+
const done = list.filter((td) => td.status === "done").length;
|
|
441
|
+
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
442
|
+
clearTodos(); // emits → the panel unmounts via onTodosChange
|
|
443
|
+
}
|
|
421
444
|
if (images?.length)
|
|
422
445
|
noteVisionIfNeeded(); // one-shot inline notice on first image of the session
|
|
423
446
|
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
|
|
@@ -482,20 +505,6 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
482
505
|
setCurrent([]);
|
|
483
506
|
setWorking(false);
|
|
484
507
|
ctrlRef.current = null;
|
|
485
|
-
// Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
|
|
486
|
-
// in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
|
|
487
|
-
if (collapseTimerRef.current)
|
|
488
|
-
clearTimeout(collapseTimerRef.current);
|
|
489
|
-
if (currentTodos().length) {
|
|
490
|
-
collapseTimerRef.current = setTimeout(() => {
|
|
491
|
-
const list = currentTodos();
|
|
492
|
-
if (!list.length)
|
|
493
|
-
return;
|
|
494
|
-
const done = list.filter((t) => t.status === "done").length;
|
|
495
|
-
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
496
|
-
collapseTimerRef.current = null;
|
|
497
|
-
}, 30_000);
|
|
498
|
-
}
|
|
499
508
|
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
500
509
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
501
510
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
@@ -578,5 +587,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
578
587
|
});
|
|
579
588
|
if (showTranscript)
|
|
580
589
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
581
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))),
|
|
590
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), _jsx(TodoPanel, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
582
591
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The framed input box (ink): a rounded, dim-bordered box (codex polish) wrapping the prompt line,
|
|
3
|
-
// with a single dim footer line rendered BELOW the box (model · approval · route · cwd · usage · ctx)
|
|
4
|
-
//
|
|
3
|
+
// with a single dim footer line rendered BELOW the box (model · approval · route · cwd · usage · ctx).
|
|
4
|
+
// The approval-mode picker and working/queue status live OUTSIDE this component (App's constant-height
|
|
5
|
+
// StatusRow/ModeLine slot above the box). Pure-ish: pass `width` for deterministic tests.
|
|
5
6
|
//
|
|
6
7
|
// Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
|
|
7
8
|
// ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
|
|
@@ -35,7 +36,7 @@ export function footerCwd(abs, home = process.env.HOME ?? "", maxLen = 28) {
|
|
|
35
36
|
const slash = tail.indexOf("/");
|
|
36
37
|
return "…" + (slash > 0 ? tail.slice(slash) : tail);
|
|
37
38
|
}
|
|
38
|
-
/** Approval-mode accent color, shared by the footer indicator +
|
|
39
|
+
/** Approval-mode accent color, shared by the footer indicator + App's transient ModeLine. full-auto is
|
|
39
40
|
* the dangerous one (red), plan is read-only (cyan), the edit modes are green. */
|
|
40
41
|
export function approvalColor(a) {
|
|
41
42
|
return a === "full-auto" ? "red" : a === "plan" ? "cyan" : "green";
|
|
@@ -79,20 +80,6 @@ const TopBorder = memo(function TopBorder({ name, width }) {
|
|
|
79
80
|
const left = Math.max(2, width - name.length - 7); // ╭(1)+ " "(1)+●(1)+" name "(len+2)+"─╮"(2)
|
|
80
81
|
return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "╭" + "─".repeat(left) + " " }), _jsx(Text, { color: "cyan", children: "\u25CF" }), _jsx(Text, { bold: true, children: ` ${name} ` }), _jsx(Text, { dimColor: true, children: "─╮" })] }));
|
|
81
82
|
});
|
|
82
|
-
const MODE_DESC = {
|
|
83
|
-
suggest: "confirms edits & commands",
|
|
84
|
-
"auto-edit": "auto-applies edits · asks before commands",
|
|
85
|
-
"full-auto": "runs everything — no prompts ⚠",
|
|
86
|
-
plan: "investigate read-only, then propose a plan to approve",
|
|
87
|
-
};
|
|
88
|
-
// Transient approval-mode selector: popped by shift+tab and auto-hidden after a beat (App owns the
|
|
89
|
-
// timer) so it isn't always-on chrome. All modes listed, the active one highlighted (red for the
|
|
90
|
-
// dangerous full-auto) with a one-line description and the shift+tab hint. When hidden, the current
|
|
91
|
-
// mode still reads (colored) from the footer line — this just adds the full picker + descriptions.
|
|
92
|
-
const ModeBar = memo(function ModeBar({ approval }) {
|
|
93
|
-
const warn = approval === "full-auto";
|
|
94
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: warn ? "red" : m === "plan" ? "cyan" : "green", bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))) }), _jsx(Text, { dimColor: true, children: ` ${MODE_DESC[approval]} · shift+tab ⇄` })] }));
|
|
95
|
-
});
|
|
96
83
|
/** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
|
|
97
84
|
function activeMention(value, cursor) {
|
|
98
85
|
const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
|
|
@@ -242,9 +229,9 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
|
|
|
242
229
|
}
|
|
243
230
|
return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i))) }));
|
|
244
231
|
});
|
|
245
|
-
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx)
|
|
246
|
-
*
|
|
247
|
-
export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true,
|
|
232
|
+
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx),
|
|
233
|
+
* with an @path popup. */
|
|
234
|
+
export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
|
|
248
235
|
const { stdout } = useStdout();
|
|
249
236
|
const w = width ?? stdout?.columns ?? 80;
|
|
250
237
|
const [value, setValue] = useState("");
|
|
@@ -408,5 +395,5 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
408
395
|
// + corners and everything aligns column-for-column.
|
|
409
396
|
const innerW = Math.max(1, w - 4);
|
|
410
397
|
const cwdShort = footerCwd(cwd);
|
|
411
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(Box, { borderStyle: "round", borderTop: false, borderColor: "gray", borderDimColor: true, paddingX: 1, width: w, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }),
|
|
398
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(Box, { borderStyle: "round", borderTop: false, borderColor: "gray", borderDimColor: true, paddingX: 1, width: w, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }), popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null] }));
|
|
412
399
|
}
|