@siuver/omp-debug-mode 0.1.5 โ†’ 0.1.7

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/src/ui.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
- import { describeOpenReason } from "./gate";
3
- import { PROCEED_REMINDER } from "./methodology";
4
- import type { DebugSession, DebugState, EvidenceRequest } from "./state";
2
+ import type { DebugSession, DebugState, EvidenceRequest, HandoffMode } from "./state";
5
3
  import { currentRound, pendingRequests } from "./state";
6
4
 
7
5
  export const WIDGET_MAX_LINES = 10;
8
6
  const WIDGET_MAX_WIDTH = 90;
9
7
 
10
- export type WidgetTone = "accent" | "dim";
8
+ /** Above the editor: what this round asked the user to do. */
9
+ export const WIDGET_CONTEXT_KEY = "debug-mode";
10
+ /** Below the editor: the commands that finish or keep the round. */
11
+ export const WIDGET_ACTIONS_KEY = "debug-mode-actions";
12
+
13
+ export type WidgetTone = "accent" | "dim" | "warning";
11
14
 
12
15
  export interface WidgetLine {
13
16
  text: string;
@@ -15,23 +18,54 @@ export interface WidgetLine {
15
18
  }
16
19
 
17
20
  export function statusLabel(session: DebugSession): string {
18
- const round = currentRound(session).index;
19
- if (session.stage === "awaiting_evidence") return "๐Ÿž waiting-repro";
20
- if (session.stage === "open") return `๐Ÿž round ${round} ยท your turn`;
21
+ const round = currentRound(session);
21
22
  if (session.stage === "cleaning_up") return "๐Ÿž cleanup";
22
- return `๐Ÿž round ${round}`;
23
+ if (session.stage === "investigating") return `๐Ÿž round ${round.index}`;
24
+ const mode = round.handoff ?? "incomplete";
25
+ if (mode === "reproduce") return `๐Ÿž round ${round.index} ยท reproduce`;
26
+ if (mode === "capture") return `๐Ÿž round ${round.index} ยท capture`;
27
+ if (mode === "question") return `๐Ÿž round ${round.index} ยท your reply`;
28
+ return `๐Ÿž round ${round.index} ยท needs input`;
23
29
  }
24
30
 
31
+ /**
32
+ * Clip to terminal columns, not to code units. Reproduction steps are written
33
+ * in whatever language the user reports the bug in, and a CJK step counts one
34
+ * unit per character while occupying two columns โ€” measured by `length` a line
35
+ * of Chinese fits the budget at twice the real width and the host wraps or
36
+ * truncates it instead.
37
+ */
25
38
  function clip(text: string): string {
26
- return text.length > WIDGET_MAX_WIDTH ? `${text.slice(0, WIDGET_MAX_WIDTH - 1)}โ€ฆ` : text;
39
+ if (Bun.stringWidth(text) <= WIDGET_MAX_WIDTH) return text;
40
+ let width = 0;
41
+ let kept = "";
42
+ for (const char of text) {
43
+ const next = width + Bun.stringWidth(char);
44
+ if (next > WIDGET_MAX_WIDTH - 1) break;
45
+ width = next;
46
+ kept += char;
47
+ }
48
+ return `${kept}โ€ฆ`;
49
+ }
50
+
51
+ function paint(ctx: ExtensionContext, lines: WidgetLine[]): string[] {
52
+ return lines.map(line => ctx.ui.theme.fg(line.tone, line.text));
53
+ }
54
+
55
+ function clearWidgets(ctx: ExtensionContext): void {
56
+ ctx.ui.setWidget(WIDGET_CONTEXT_KEY, undefined);
57
+ ctx.ui.setWidget(WIDGET_ACTIONS_KEY, undefined);
27
58
  }
28
59
 
29
- /** The command surface is the only interaction path at the gate. */
30
- const COMMAND_LINES = [
31
- "/debug-proceed [details]",
32
- "/debug-evidence <request-id> <path>",
33
- "/debug-done ยท /debug-abort ยท /debug-status",
34
- ] as const;
60
+ const TIPS_HEADER = "Tips:";
61
+
62
+ /**
63
+ * Remaining commands after the exits have their own lines. The `-`
64
+ * sentinel is deliberately not spelled out here: `<request-id|->` renders as an
65
+ * arrow next to lines that use `โ†’` to mean "and then this happens", and
66
+ * the command's own completion offers `-` as its first entry anyway.
67
+ */
68
+ const OTHER_COMMANDS = "/debug-evidence <request-id> <path> ยท /debug-clear ยท /debug-abort ยท /debug-status";
35
69
 
36
70
  function plural(count: number, singular: string): string {
37
71
  return `${count} ${singular}${count === 1 ? "" : "s"}`;
@@ -42,86 +76,126 @@ function logEntries(count: number): string {
42
76
  }
43
77
 
44
78
  /**
45
- * A round that settled without closing needs its own affordance: the user has
46
- * the turn, but the answer is an ordinary reply, not a reproduction. Showing
47
- * nothing here is what makes an unclosed round look like the gate.
79
+ * What this round wants from the user, without naming a command: the actions
80
+ * widget sits directly under the editor and names all of them already.
48
81
  */
49
- export function openWidgetLines(session: DebugSession): WidgetLine[] {
50
- const round = currentRound(session);
51
- const reason = round.openReason ?? "awaiting_reply";
52
- return [
53
- { text: "This round is not closed โ€” reply to continue.", tone: "accent" },
54
- { text: clip(describeOpenReason(reason, round.index)), tone: "dim" },
55
- { text: "/debug-proceed closes it anyway ยท /debug-status ยท /debug-abort", tone: "dim" },
56
- ];
82
+ function callToAction(mode: HandoffMode): string {
83
+ if (mode === "reproduce") return "Reproduce the bug now.";
84
+ if (mode === "capture") return "Supply the requested report or file.";
85
+ if (mode === "question") return "Reply in the editor. The agent is waiting on an answer, not a reproduction.";
86
+ return "The agent stopped without saying what to reproduce or capture.";
87
+ }
88
+
89
+ /**
90
+ * What `/debug-proceed` does right now, with the numbers it would act on.
91
+ * The actions strip is a dim cheat-sheet under the editor, so this line never
92
+ * lights up: captured vs empty is in the wording, not the tone.
93
+ */
94
+ export function proceedLine(session: DebugSession, logCount: number): WidgetLine {
95
+ const captured: string[] = [];
96
+ if (logCount > 0) captured.push(logEntries(logCount));
97
+ if (session.observations.length > 0) captured.push(plural(session.observations.length, "observation"));
98
+ if (session.artifacts.length > 0) captured.push(plural(session.artifacts.length, "artifact"));
99
+ return {
100
+ text: clip(
101
+ captured.length > 0
102
+ ? `/debug-proceed โ†’ close this round, analyze ${captured.join(", ")}`
103
+ : "/debug-proceed โ†’ close this round with nothing captured yet",
104
+ ),
105
+ tone: "dim",
106
+ };
107
+ }
108
+
109
+ /** The other exit: the bug is actually gone. */
110
+ export function doneLine(): WidgetLine {
111
+ return {
112
+ text: clip("/debug-done โ†’ bug is fixed: remove probes and summarize"),
113
+ tone: "dim",
114
+ };
115
+ }
116
+
117
+ /** A model ordinal in any of the shapes the prompt's numbered list produces. */
118
+ const LEADING_ORDINAL = /^\s*\d+\s*[.)ใ€]\s*/;
119
+
120
+ /**
121
+ * Every step carries the widget's own ordinal. The model numbers its list about
122
+ * as often as it does not, and an unnumbered run of dim lines reads as one
123
+ * paragraph rather than as a sequence to work through.
124
+ */
125
+ export function numberedSteps(steps: readonly string[]): string[] {
126
+ const kept: string[] = [];
127
+ for (const step of steps) {
128
+ // The commands are named once, under the editor. A step that repeats them
129
+ // is the third copy on one screen, and the prompt forbids writing it.
130
+ if (/\/debug-(?:proceed|done)\b/i.test(step)) continue;
131
+ const body = step.replace(LEADING_ORDINAL, "").trim();
132
+ if (body.length === 0) continue;
133
+ kept.push(`${kept.length + 1}. ${body}`);
134
+ }
135
+ return kept;
136
+ }
137
+
138
+ function appendFoldedSteps(lines: WidgetLine[], steps: readonly string[]): void {
139
+ const stepBudget = Math.max(0, WIDGET_MAX_LINES - lines.length);
140
+ if (stepBudget === 0 || steps.length === 0) return;
141
+ const shownCount = steps.length > stepBudget ? Math.max(0, stepBudget - 1) : stepBudget;
142
+ for (const step of steps.slice(0, shownCount)) lines.push({ text: clip(step), tone: "dim" });
143
+ const hidden = steps.length - shownCount;
144
+ if (hidden > 0) lines.push({ text: `โ€ฆ +${plural(hidden, "more step")} in the transcript`, tone: "dim" });
57
145
  }
58
146
 
59
147
  /**
60
- * Reproduction-gate widget: the call to action, pending user evidence,
61
- * reproduction-step context, the command surface, and the live evidence
62
- * counter. Commands are the only interaction path โ€” there is no menu. The
63
- * live log counter is always the final line within the host's line budget.
148
+ * Above the editor: the work this round asked for. Mode changes the wording
149
+ * and whether stale reproduction steps are worth repeating, never which
150
+ * commands the actions widget offers.
64
151
  */
65
- export function waitingWidgetLines(
152
+ export function userTurnContextLines(
66
153
  session: DebugSession,
67
- logCount: number,
68
154
  pendingEvidence: EvidenceRequest[] = pendingRequests(session),
69
155
  ): WidgetLine[] {
70
156
  const round = currentRound(session);
71
- const lines: WidgetLine[] = [{ text: PROCEED_REMINDER, tone: "accent" }];
157
+ const mode = round.handoff ?? "incomplete";
158
+ const lines: WidgetLine[] = [{ text: clip(callToAction(mode)), tone: mode === "incomplete" ? "warning" : "accent" }];
72
159
  for (const request of pendingEvidence.slice(0, 2)) {
73
160
  lines.push({ text: clip(`โ†ช ${request.id} ${request.title}: ${request.instructions[0] ?? ""}`), tone: "accent" });
74
161
  }
75
- for (const command of COMMAND_LINES) lines.push({ text: command, tone: "accent" });
76
-
77
- // Reproduction details are useful context, but command discoverability and
78
- // the live log counter must survive the host's ten-line widget limit.
79
- const reserved = lines.length + 2;
80
- const stepBudget = Math.max(0, WIDGET_MAX_LINES - reserved);
81
- if (stepBudget > 0 && round.reproductionSteps.length > 0) {
82
- const showMoreLine = round.reproductionSteps.length > stepBudget;
83
- const shownCount = showMoreLine ? Math.max(0, stepBudget - 1) : stepBudget;
84
- for (const step of round.reproductionSteps.slice(0, shownCount)) {
85
- lines.push({ text: clip(step), tone: "dim" });
86
- }
87
- const hidden = round.reproductionSteps.length - shownCount;
88
- if (hidden > 0) lines.push({ text: `โ€ฆ +${plural(hidden, "more step")} in the transcript`, tone: "dim" });
89
- }
90
- lines.push({
91
- text: `evidence: ${plural(pendingEvidence.length, "pending request")}, ${plural(session.artifacts.length, "attached artifact")}`,
92
- tone: pendingEvidence.length > 0 || session.artifacts.length > 0 ? "accent" : "dim",
93
- });
94
- lines.push({
95
- text: `run ${round.runId ?? "none"} โ€” ${logEntries(logCount)}`,
96
- tone: logCount > 0 ? "accent" : "dim",
97
- });
162
+ const steps = mode === "reproduce" || mode === "capture" ? numberedSteps(round.reproductionSteps) : [];
163
+ appendFoldedSteps(lines, steps);
98
164
  return lines.slice(0, WIDGET_MAX_LINES);
99
165
  }
100
166
 
101
167
  /**
102
- * Render the status entry and the stage widget. `getLogCount` is only consulted
103
- * at the reproduction gate so idle stages do not touch the log files.
168
+ * Below the editor: a dim cheat-sheet next to the prompt so the user does not
169
+ * have to hunt above the transcript for what to type. A reply in the editor is
170
+ * still a legal exit โ€” it just is not listed here, because the editor is
171
+ * already where that line would have pointed.
172
+ */
173
+ export function userTurnActionLines(session: DebugSession, logCount: number): WidgetLine[] {
174
+ return [
175
+ { text: TIPS_HEADER, tone: "dim" },
176
+ proceedLine(session, logCount),
177
+ doneLine(),
178
+ { text: clip(OTHER_COMMANDS), tone: "dim" },
179
+ ];
180
+ }
181
+
182
+ /**
183
+ * Render the footer status and the two stage widgets. `getLogCount` is only
184
+ * consulted on the user's turn so idle stages do not touch the log files.
104
185
  */
105
186
  export function applyUi(ctx: ExtensionContext | null, state: DebugState, getLogCount: () => number): void {
106
187
  if (!ctx?.hasUI) return;
107
188
  if (!state.active) {
108
189
  ctx.ui.setStatus("debug-mode", undefined);
109
- ctx.ui.setWidget("debug-mode", undefined);
190
+ clearWidgets(ctx);
110
191
  return;
111
192
  }
112
193
  ctx.ui.setStatus("debug-mode", ctx.ui.theme.fg("warning", statusLabel(state)));
113
- const lines =
114
- state.stage === "awaiting_evidence"
115
- ? waitingWidgetLines(state, getLogCount())
116
- : state.stage === "open"
117
- ? openWidgetLines(state)
118
- : null;
119
- if (!lines) {
120
- ctx.ui.setWidget("debug-mode", undefined);
194
+ if (state.stage !== "user_turn") {
195
+ clearWidgets(ctx);
121
196
  return;
122
197
  }
123
- ctx.ui.setWidget(
124
- "debug-mode",
125
- lines.map(line => ctx.ui.theme.fg(line.tone, line.text)),
126
- );
198
+ const logCount = getLogCount();
199
+ ctx.ui.setWidget(WIDGET_CONTEXT_KEY, paint(ctx, userTurnContextLines(state)));
200
+ ctx.ui.setWidget(WIDGET_ACTIONS_KEY, paint(ctx, userTurnActionLines(state, logCount)), { placement: "belowEditor" });
127
201
  }