@timurproko/a1 0.2.7-dev.1266 → 0.2.7-dev.1269

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.
Files changed (57) hide show
  1. package/README.md +2 -0
  2. package/dist/app/session-shell/session-name-reveal.d.ts +17 -0
  3. package/dist/app/session-shell/session-name-reveal.js +49 -0
  4. package/dist/app/session-shell/session-shell-root.d.ts +2 -1
  5. package/dist/app/session-shell/session-shell-root.js +14 -9
  6. package/dist/app/session-shell/session-shell.js +6 -0
  7. package/dist/app/session-shell/session-terminal-presentation.d.ts +7 -0
  8. package/dist/app/session-shell/session-terminal-presentation.js +11 -1
  9. package/dist/app/session-shell/shell-host.js +3 -1
  10. package/dist/composition/owned-ui.js +8 -6
  11. package/dist/composition/session-backend.d.ts +2 -1
  12. package/dist/composition/session-naming.d.ts +6 -0
  13. package/dist/composition/session-naming.js +23 -0
  14. package/dist/composition/session-runtime-registry.d.ts +5 -1
  15. package/dist/composition/session-runtime-registry.js +11 -3
  16. package/dist/contracts/owned-ui/index.d.ts +1 -0
  17. package/dist/contracts/owned-ui/model.d.ts +6 -0
  18. package/dist/contracts/owned-ui/session-naming.d.ts +22 -0
  19. package/dist/contracts/owned-ui/session-naming.js +1 -0
  20. package/dist/contracts/owned-ui/shell-components.d.ts +5 -0
  21. package/dist/contracts/owned-ui/validation.js +5 -0
  22. package/dist/features/owned-ui/settings-app.js +10 -2
  23. package/dist/features/session-naming/controller.d.ts +23 -0
  24. package/dist/features/session-naming/controller.js +149 -0
  25. package/dist/features/session-naming/generate.d.ts +5 -0
  26. package/dist/features/session-naming/generate.js +54 -0
  27. package/dist/features/session-naming/index.d.ts +2 -0
  28. package/dist/features/session-naming/index.js +1 -0
  29. package/dist/foundation/lifecycle/index.d.ts +2 -2
  30. package/dist/foundation/lifecycle/index.js +1 -1
  31. package/dist/foundation/lifecycle/session-repository-context.d.ts +12 -0
  32. package/dist/foundation/lifecycle/session-repository-context.js +59 -3
  33. package/dist/integrations/pi/components/shell-footer-status.js +1 -0
  34. package/dist/integrations/pi/components/shell-presenters-transcript.js +3 -6
  35. package/dist/integrations/pi/components/upstream/components/session-footer.d.ts +5 -0
  36. package/dist/integrations/pi/components/upstream/components/session-footer.js +44 -7
  37. package/dist/integrations/pi/components/upstream/components/tool-execution.js +4 -8
  38. package/dist/integrations/pi/engine/adapter.d.ts +3 -2
  39. package/dist/integrations/pi/engine/adapter.js +37 -28
  40. package/dist/integrations/pi/engine/repository-metadata.d.ts +2 -0
  41. package/dist/integrations/pi/engine/repository-metadata.js +12 -16
  42. package/dist/integrations/pi/engine/session-events.d.ts +1 -0
  43. package/dist/integrations/pi/engine/session-events.js +2 -0
  44. package/dist/integrations/pi/engine/session-naming.d.ts +24 -0
  45. package/dist/integrations/pi/engine/session-naming.js +88 -0
  46. package/dist/integrations/pi/engine/view-state.d.ts +5 -1
  47. package/dist/integrations/pi/engine/view-state.js +3 -1
  48. package/dist/ui/components/index.d.ts +1 -0
  49. package/dist/ui/components/index.js +1 -0
  50. package/dist/ui/components/session-name-frame.d.ts +17 -0
  51. package/dist/ui/components/session-name-frame.js +24 -0
  52. package/dist/ui/settings/declarations.d.ts +24 -0
  53. package/dist/ui/settings/declarations.js +18 -0
  54. package/docs/architecture/project-structure.md +1 -0
  55. package/docs/architecture/tool-image-presentation.md +3 -3
  56. package/docs/features/session-naming.md +37 -0
  57. package/package.json +3 -3
@@ -0,0 +1,149 @@
1
+ function contextKey(context) {
2
+ return JSON.stringify([context.cwd.replace(/\\/g, "/"), context.branch, context.revision]);
3
+ }
4
+ /** One background naming chain per eligible prompt/context; owns no timers or engine objects. */
5
+ export class SessionNamingController {
6
+ #options;
7
+ #unsubscribe;
8
+ #binding;
9
+ #seen = new Set();
10
+ #signature;
11
+ #epoch = 0;
12
+ #run;
13
+ #work;
14
+ #attempted;
15
+ #disposed = false;
16
+ constructor(options) {
17
+ this.#options = options;
18
+ this.#binding = options.backend.identity.sessionBindingGeneration;
19
+ this.#rememberTranscript();
20
+ this.#unsubscribe = options.backend.session.onEvent(event => this.#event(event));
21
+ }
22
+ settingsChanged() { if (!this.#options.enabled())
23
+ this.#invalidate(); }
24
+ dispose() {
25
+ if (this.#disposed)
26
+ return;
27
+ this.#disposed = true;
28
+ this.#unsubscribe();
29
+ this.#invalidate();
30
+ }
31
+ #invalidate() {
32
+ this.#epoch++;
33
+ this.#attempted = undefined;
34
+ this.#run?.abort.abort();
35
+ this.#run = undefined;
36
+ this.#work?.abort.abort();
37
+ this.#work = undefined;
38
+ }
39
+ #rememberTranscript() {
40
+ this.#seen = new Set(this.#options.backend.session.view().transcript.filter(block => block.kind === "user").map(block => block.id));
41
+ }
42
+ #event(event) {
43
+ if (this.#disposed)
44
+ return;
45
+ const { backend } = this.#options;
46
+ if (this.#binding !== backend.identity.sessionBindingGeneration) {
47
+ this.#binding = backend.identity.sessionBindingGeneration;
48
+ this.#invalidate();
49
+ this.#attempted = undefined;
50
+ this.#signature = undefined;
51
+ this.#rememberTranscript();
52
+ }
53
+ if (event.type === "session-view") {
54
+ const footer = event.view.status.footer;
55
+ const signature = JSON.stringify([footer?.repositoryPath, footer?.branch, footer?.repositoryRevision]);
56
+ if (this.#signature !== undefined && signature !== this.#signature)
57
+ this.#checkContext();
58
+ this.#signature = signature;
59
+ if (this.#run)
60
+ for (const block of event.view.transcript)
61
+ if (block.kind === "user")
62
+ this.#prompt(block.id, block.text);
63
+ }
64
+ if (event.type === "agent-run-started") {
65
+ this.#run?.abort.abort();
66
+ const run = { epoch: this.#epoch, abort: new AbortController() };
67
+ this.#run = run;
68
+ if (!this.#options.enabled())
69
+ return;
70
+ const state = this.#options.naming.state();
71
+ if (!state)
72
+ return;
73
+ run.state = state;
74
+ void this.#options.withContext(context => context, run.abort.signal).then(context => {
75
+ if (this.#run !== run || run.epoch !== this.#epoch || run.abort.signal.aborted
76
+ || state.binding !== backend.identity.sessionBindingGeneration)
77
+ return;
78
+ run.context = context;
79
+ if (this.#work && this.#work.key !== contextKey(context)) {
80
+ this.#work.abort.abort();
81
+ this.#work = undefined;
82
+ }
83
+ this.#launch(run);
84
+ }).catch(() => { });
85
+ for (const block of backend.session.view().transcript)
86
+ if (block.kind === "user")
87
+ this.#prompt(block.id, block.text);
88
+ }
89
+ if (event.type === "transcript-block" && event.block.kind === "user")
90
+ this.#prompt(event.block.id, event.block.text);
91
+ if (event.type === "agent-run-settled" && this.#run?.prompt === undefined) {
92
+ this.#run?.abort.abort();
93
+ this.#run = undefined;
94
+ }
95
+ if (event.type === "session-lifecycle" && ["stopping", "stopped", "failed"].includes(event.lifecycle))
96
+ this.#invalidate();
97
+ }
98
+ #checkContext() {
99
+ const run = this.#run;
100
+ if (!run)
101
+ return;
102
+ run.checking = true;
103
+ void this.#options.withContext(context => context, run.abort.signal).then(current => {
104
+ if (this.#run !== run || run.abort.signal.aborted || this.#disposed)
105
+ return;
106
+ if (run.context && contextKey(current) !== contextKey(run.context)) {
107
+ this.#invalidate();
108
+ return;
109
+ }
110
+ run.checking = false;
111
+ this.#launch(run);
112
+ }).catch(() => { });
113
+ }
114
+ #prompt(id, text) {
115
+ if (this.#seen.has(id))
116
+ return;
117
+ this.#seen.add(id);
118
+ const run = this.#run;
119
+ if (!run || !text.trim() || text.trimStart().startsWith("/"))
120
+ return;
121
+ run.prompt = text.trim();
122
+ this.#launch(run);
123
+ }
124
+ #launch(run) {
125
+ const { context, state, prompt } = run;
126
+ if (!context || !state || !prompt || run.checking || this.#disposed || !this.#options.enabled()
127
+ || run.abort.signal.aborted || run.epoch !== this.#epoch || state.source === "user")
128
+ return;
129
+ const repositoryKey = contextKey(context);
130
+ const key = JSON.stringify([state.sessionFile, repositoryKey]);
131
+ if (state.completedContext === key || this.#attempted === key || this.#work)
132
+ return;
133
+ this.#attempted = key;
134
+ const work = new AbortController();
135
+ this.#work = { abort: work, key: repositoryKey };
136
+ void import("./generate.js").then(async ({ formatSessionName, generateSessionNameCandidate }) => {
137
+ const candidate = await generateSessionNameCandidate(prompt, `${context.cwd.split(/[\\/]/).at(-1) ?? ""} (${context.branch ?? "no branch"})`, request => this.#options.naming.complete(request), work.signal, this.#options.format);
138
+ if (work.signal.aborted || this.#disposed || !this.#options.enabled())
139
+ return;
140
+ return this.#options.withContext(current => {
141
+ if (contextKey(current) !== repositoryKey || work.signal.aborted || this.#disposed || !this.#options.enabled())
142
+ return false;
143
+ const name = formatSessionName(candidate, this.#options.format());
144
+ return name !== null && this.#options.naming.apply(state, name, key);
145
+ }, work.signal);
146
+ }).catch(() => { }).finally(() => { if (this.#work?.abort === work)
147
+ this.#work = undefined; });
148
+ }
149
+ }
@@ -0,0 +1,5 @@
1
+ import type { OwnedUiSessionNameCompletion } from "../../contracts/owned-ui/index.js";
2
+ export type SessionNameFormat = "kebab" | "sentence" | "title";
3
+ export declare function formatSessionName(value: string, format: SessionNameFormat): string | null;
4
+ /** Two bounded tool-free attempts; cancellation never becomes an automatic fallback. */
5
+ export declare function generateSessionNameCandidate(prompt: string, hints: string, complete: (request: OwnedUiSessionNameCompletion) => Promise<string | null>, signal: AbortSignal, format: () => SessionNameFormat, timeoutMs?: number): Promise<string>;
@@ -0,0 +1,54 @@
1
+ export function formatSessionName(value, format) {
2
+ const words = (format === "kebab" ? value.toLowerCase() : value).match(/[a-z0-9]+/gi) ?? [];
3
+ const name = words.map((word, index) => {
4
+ if (format === "kebab")
5
+ return word;
6
+ if (/^[A-Z0-9]+$/.test(word) || /[a-z][A-Z]/.test(word))
7
+ return word;
8
+ const lower = word.toLowerCase();
9
+ return format === "title" || index === 0 ? lower[0].toUpperCase() + lower.slice(1) : lower;
10
+ }).join(format === "kebab" ? "-" : " ");
11
+ if (!name)
12
+ return null;
13
+ if (name.length <= 16)
14
+ return name;
15
+ const boundary = name.slice(0, 17).lastIndexOf(format === "kebab" ? "-" : " ");
16
+ return (boundary >= 4 ? name.slice(0, boundary) : name.slice(0, 16)).replace(/[- ]+$/g, "") || null;
17
+ }
18
+ /** Two bounded tool-free attempts; cancellation never becomes an automatic fallback. */
19
+ export async function generateSessionNameCandidate(prompt, hints, complete, signal, format, timeoutMs = 30_000) {
20
+ for (const maxTokens of [4_096, 32_768]) {
21
+ signal.throwIfAborted();
22
+ const deadline = new AbortController();
23
+ const attempt = AbortSignal.any([signal, deadline.signal]);
24
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
25
+ timer.unref?.();
26
+ let aborted;
27
+ try {
28
+ const cancelled = new Promise((_, reject) => {
29
+ aborted = () => reject(attempt.reason);
30
+ attempt.addEventListener("abort", aborted, { once: true });
31
+ });
32
+ const requestedFormat = format();
33
+ const style = requestedFormat === "kebab" ? "a lowercase hyphenated title" : `a ${requestedFormat === "sentence" ? "sentence case" : "title case"} title with spaces, preserving acronyms and technical spelling`;
34
+ const response = await Promise.race([complete({
35
+ prompt: `Name this coding task with ${style} of at most 16 characters. Reply only with the title.\nRepository hints: ${hints.slice(0, 300)}\n<task>\n${prompt.slice(0, 500)}\n</task>`,
36
+ maxTokens, signal: attempt,
37
+ }), cancelled]);
38
+ signal.throwIfAborted();
39
+ if (response !== null && formatSessionName(response, format()) !== null)
40
+ return response;
41
+ }
42
+ catch {
43
+ signal.throwIfAborted();
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ if (aborted)
48
+ attempt.removeEventListener("abort", aborted);
49
+ }
50
+ }
51
+ signal.throwIfAborted();
52
+ const fallback = (prompt.match(/[a-z0-9]+/gi) ?? []).slice(0, 3).join(" ");
53
+ return fallback || "new task";
54
+ }
@@ -0,0 +1,2 @@
1
+ export { SessionNamingController } from "./controller.js";
2
+ export type { SessionNamingContext } from "./controller.js";
@@ -0,0 +1 @@
1
+ export { SessionNamingController } from "./controller.js";
@@ -5,5 +5,5 @@ export { resolveProductPaths } from "./paths.js";
5
5
  export type { ProductPaths } from "./paths.js";
6
6
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
7
7
  export type { SessionSelection } from "./session-selection.js";
8
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, } from "./session-repository-context.js";
9
- export type { SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
8
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
9
+ export type { SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionRepositoryObservation, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
@@ -2,4 +2,4 @@ export { assertLaunchProfileId } from "./model.js";
2
2
  export { PID_LIVENESS_IDENTITY, inspectLiveProcess } from "./process-liveness.js";
3
3
  export { resolveProductPaths } from "./paths.js";
4
4
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
5
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, } from "./session-repository-context.js";
5
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
@@ -37,5 +37,17 @@ export declare function setSessionRepositoryContext(identity: SessionRepositoryI
37
37
  export declare function clearSessionRepositoryContext(identity: SessionRepositoryIdentity, options?: SessionRepositoryContextOptions): Promise<void>;
38
38
  /** Read and revalidate a persisted association; malformed or stale state fails closed to absence. */
39
39
  export declare function readSessionRepositoryContext(identity: SessionRepositoryIdentity, options?: SessionRepositoryContextOptions): Promise<SessionRepositoryContext | null>;
40
+ /** A verified naming context. Revision changes on explicit association transitions, not heartbeats. */
41
+ export interface SessionRepositoryObservation {
42
+ readonly cwd: string;
43
+ readonly branch: string | null;
44
+ readonly revision: string;
45
+ }
46
+ /**
47
+ * Observe and synchronously consume a context under the existing mutation gate. Missing records
48
+ * mean unlinked; malformed, inaccessible, or invalid associations throw instead of inventing exits.
49
+ * The callback must not perform asynchronous work; provider I/O never holds this gate.
50
+ */
51
+ export declare function withSessionRepositoryObservation<T>(identity: SessionRepositoryIdentity, consume: (context: SessionRepositoryObservation) => T, options?: SessionRepositoryContextOptions): Promise<T>;
40
52
  /** List same-repository worktrees without acquiring, releasing, or repairing a claim. */
41
53
  export declare function listSessionRepositoryWorktrees(identity: SessionRepositoryIdentity, options?: SessionRepositoryContextOptions): Promise<readonly SessionWorktreeInventoryEntry[]>;
@@ -86,9 +86,12 @@ export async function setSessionRepositoryContext(identity, worktreePath, option
86
86
  if (status === "unverifiable")
87
87
  throw contextError("worktree ownership cannot be verified", "worktree-owner-unverifiable");
88
88
  const prior = await readContextRecord(header, options);
89
+ const unchanged = prior !== null && samePath(prior.worktreeGitDir, selected.gitDir)
90
+ && await validateContextRecord(header, prior, options.signal).then(() => true, () => false);
91
+ const revision = unchanged ? prior.revision ?? randomUUID() : randomUUID();
89
92
  await writeClaim(header, selected, owner, processIdentityOf(runtime), options);
90
93
  try {
91
- await writeContextRecord(header, startup, selected, options);
94
+ await writeContextRecord(header, startup, selected, revision, options);
92
95
  }
93
96
  catch (error) {
94
97
  if (status !== "current")
@@ -106,7 +109,7 @@ export async function setSessionRepositoryContext(identity, worktreePath, option
106
109
  export async function clearSessionRepositoryContext(identity, options = {}) {
107
110
  const header = await readSessionHeader(identity);
108
111
  if (options.runtimeId === undefined) {
109
- await rm(contextRecordPath(header, options), { force: true });
112
+ await withClaimLock(options, async () => { await rm(contextRecordPath(header, options), { force: true }); });
110
113
  return;
111
114
  }
112
115
  const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
@@ -131,6 +134,57 @@ export async function readSessionRepositoryContext(identity, options = {}) {
131
134
  const selected = await validateContextRecord(header, record, options.signal).catch(() => null);
132
135
  return selected === null ? null : { cwd: selected.root, branch: requireBranch(selected) };
133
136
  }
137
+ /**
138
+ * Observe and synchronously consume a context under the existing mutation gate. Missing records
139
+ * mean unlinked; malformed, inaccessible, or invalid associations throw instead of inventing exits.
140
+ * The callback must not perform asynchronous work; provider I/O never holds this gate.
141
+ */
142
+ export async function withSessionRepositoryObservation(identity, consume, options = {}) {
143
+ const header = await readSessionHeader(identity);
144
+ return await withClaimLock(options, async () => {
145
+ let record = null;
146
+ try {
147
+ const path = contextRecordPath(header, options);
148
+ const metadata = await lstat(path);
149
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_RECORD_BYTES)
150
+ throw new Error("invalid repository context");
151
+ const value = JSON.parse(await readFile(path, { encoding: "utf8", signal: options.signal }));
152
+ if (!isContextRecord(value))
153
+ throw new Error("invalid repository context");
154
+ record = value;
155
+ }
156
+ catch (error) {
157
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
158
+ throw error;
159
+ }
160
+ let context;
161
+ if (record !== null) {
162
+ const selected = await validateContextRecord(header, record, options.signal);
163
+ const revision = record.revision ?? randomUUID();
164
+ if (record.revision === undefined)
165
+ await atomicWrite(contextRecordPath(header, options), { ...record, revision });
166
+ context = { cwd: selected.root, branch: selected.branch, revision };
167
+ }
168
+ else {
169
+ let cwd = await realpath(header.cwd);
170
+ let branch;
171
+ try {
172
+ const selected = await readGitWorktreeIdentity(cwd, false, options.signal);
173
+ cwd = selected.root;
174
+ const result = await executeGit(["branch", "--show-current"], cwd, 4_096, options.signal);
175
+ branch = result.stdout.trim() || null;
176
+ }
177
+ catch (error) {
178
+ if (!(error instanceof Error && "stderr" in error && String(error.stderr).includes("not a git repository")))
179
+ throw error;
180
+ branch = null;
181
+ }
182
+ context = { cwd, branch, revision: "unlinked" };
183
+ }
184
+ options.signal?.throwIfAborted();
185
+ return consume(context);
186
+ });
187
+ }
134
188
  /** List same-repository worktrees without acquiring, releasing, or repairing a claim. */
135
189
  export async function listSessionRepositoryWorktrees(identity, options = {}) {
136
190
  const header = await readSessionHeader(identity);
@@ -223,9 +277,10 @@ async function writeRuntimeRecord(header, runtimeId, agentId, runtimeIdentity, o
223
277
  };
224
278
  await atomicWrite(runtimeRecordPath({ runtimeId, agentId }, options), record);
225
279
  }
226
- async function writeContextRecord(header, startup, selected, options) {
280
+ async function writeContextRecord(header, startup, selected, revision, options) {
227
281
  const record = {
228
282
  schema: CONTEXT_SCHEMA,
283
+ revision,
229
284
  sessionId: header.id,
230
285
  sessionFile: header.file,
231
286
  startupRoot: startup.root,
@@ -554,6 +609,7 @@ function validPath(value) {
554
609
  }
555
610
  function isContextRecord(value) {
556
611
  return isRecord(value) && value.schema === CONTEXT_SCHEMA && validSessionId(value.sessionId)
612
+ && (value.revision === undefined || validSessionId(value.revision))
557
613
  && validPath(value.sessionFile) && validPath(value.startupRoot) && validPath(value.startupCommonDir)
558
614
  && validPath(value.worktreeRoot) && validPath(value.worktreeCommonDir) && validPath(value.worktreeGitDir)
559
615
  && typeof value.updatedAt === "string";
@@ -142,6 +142,7 @@ export function createPiShellFooter(view, cwd, profile = "pi", showLevel = () =>
142
142
  const footer = new SessionFooter(() => view, cwd, profile, showLevel);
143
143
  return {
144
144
  render: width => footer.render(width),
145
+ setNameReveal: frame => footer.setNameReveal(frame),
145
146
  invalidate: () => footer.invalidate(),
146
147
  update(next) { view = next; },
147
148
  dispose: () => footer.dispose(),
@@ -123,20 +123,17 @@ function withTranscriptImages(component, block, assets, showImages, imageWidthCe
123
123
  if (component instanceof ToolExecutionComponent)
124
124
  return component;
125
125
  const references = block.imageReferences ?? [];
126
- if (references.length === 0)
126
+ if (!showImages || references.length === 0)
127
127
  return component;
128
128
  const container = new Container();
129
129
  container.addChild(component);
130
130
  const imageProtocolUnavailable = getCapabilities().images === null;
131
131
  for (const reference of references) {
132
- if (showImages && imageProtocolUnavailable)
132
+ if (imageProtocolUnavailable)
133
133
  continue;
134
134
  container.addChild(new Spacer(1));
135
135
  const asset = assets?.resolve(reference.assetId) ?? null;
136
- if (!showImages) {
137
- container.addChild(new Text(piTheme().fg("muted", `[Image hidden: ${reference.mimeType}, ${reference.byteLength} bytes]`), 1, 0));
138
- }
139
- else if (asset === null) {
136
+ if (asset === null) {
140
137
  container.addChild(new Text(piTheme().fg("warning", `[Image unavailable: ${reference.mimeType}]`), 1, 0));
141
138
  }
142
139
  else {
@@ -1,6 +1,7 @@
1
1
  import { type Component } from "@earendil-works/pi-tui";
2
2
  import type { OwnedUiSessionViewModel } from "../../../../../contracts/owned-ui/index.js";
3
3
  export declare class SessionFooter implements Component {
4
+ #private;
4
5
  private readonly getView;
5
6
  private readonly cwd;
6
7
  private readonly profile;
@@ -8,5 +9,9 @@ export declare class SessionFooter implements Component {
8
9
  constructor(getView: () => OwnedUiSessionViewModel, cwd: string, profile?: "pi" | "a1", show?: () => boolean);
9
10
  invalidate(): void;
10
11
  dispose(): void;
12
+ setNameReveal(frame: {
13
+ readonly from: string;
14
+ readonly elapsed: number;
15
+ } | null): boolean;
11
16
  render(width: number): string[];
12
17
  }
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * Provenance: @earendil-works/pi-coding-agent 1.5.0 (MIT), commit f9a26365568127461c47a07a37dcb42c140a3ac5,
3
3
  * packages/coding-agent/src/modes/interactive/components/footer.ts.
4
- * Modifications: Owned UI/routing; A1 level color, linked PRs, dim ellipsis and model priority; Pi
5
- * unchanged.
4
+ * Modifications: Owned UI/routing; A1 level color, linked PRs, dim ellipsis, model priority, and
5
+ * secondary-accent name reveals after stats; Pi unchanged.
6
6
  * Deviations: owned-status-level-color, owned-pull-request-badge, owned-footer-ellipsis,
7
- * owned-footer-model-priority.
7
+ * owned-footer-model-priority, owned-session-name-reveal.
8
8
  */
9
9
  import { isAbsolute, relative, resolve, sep } from "node:path";
10
10
  import { hyperlink, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import { piTheme } from "../../theme.js";
12
+ import { sessionNameFrame } from "../../../../../ui/components/session-name-frame.js";
12
13
  export class SessionFooter {
13
14
  getView;
14
15
  cwd;
15
16
  profile;
16
17
  show;
18
+ #reveal = null;
19
+ #nameBudget = Number.MAX_SAFE_INTEGER;
17
20
  constructor(getView, cwd, profile = "pi", show = () => true) {
18
21
  this.getView = getView;
19
22
  this.cwd = cwd;
@@ -21,7 +24,33 @@ export class SessionFooter {
21
24
  this.show = show;
22
25
  }
23
26
  invalidate() { }
24
- dispose() { }
27
+ dispose() { this.#reveal = null; }
28
+ setNameReveal(frame) {
29
+ this.#reveal = this.profile === "a1" ? frame : null;
30
+ if (this.#reveal && this.#nameFrame(this.getView().status.footer?.sessionName ?? "", this.#nameBudget).done)
31
+ this.#reveal = null;
32
+ return this.#reveal !== null;
33
+ }
34
+ #nameFrame(name, width) {
35
+ const theme = piTheme();
36
+ const secondary = rgb(theme.getFgAnsi("mdHeading"));
37
+ const settled = rgb(theme.getFgAnsi("dim"));
38
+ if (!this.#reveal)
39
+ return { text: theme.fg("dim", truncateToWidth(name, width, "…")), done: true };
40
+ return sessionNameFrame({
41
+ ...this.#reveal, to: name, width,
42
+ truecolor: theme.getColorMode() === "truecolor" && secondary !== null && settled !== null,
43
+ measure: visibleWidth, clip: (text, columns) => truncateToWidth(text, columns, "…"),
44
+ dim: text => theme.fg("dim", text),
45
+ paint: (text, brightness, progress) => {
46
+ const channels = secondary.map((value, index) => {
47
+ const bright = value + (235 - value) * brightness;
48
+ return Math.round(bright + (settled[index] - bright) * progress);
49
+ });
50
+ return `\u001b[38;2;${channels.join(";")}m${text}\u001b[39m`;
51
+ },
52
+ });
53
+ }
25
54
  render(width) {
26
55
  const view = this.getView();
27
56
  const usage = view.status.usage;
@@ -45,13 +74,12 @@ export class SessionFooter {
45
74
  const pullRequest = this.profile === "a1" ? view.status.footer?.pullRequest : null;
46
75
  let pathRow;
47
76
  if (pullRequest == null) {
48
- pathRow = theme.fg("dim", sessionName ? `${pwd} • ${sessionName}` : pwd);
77
+ pathRow = theme.fg("dim", this.profile === "pi" && sessionName ? `${pwd} • ${sessionName}` : pwd);
49
78
  }
50
79
  else {
51
80
  const badge = theme.fg("dim", " ")
52
81
  + hyperlink(theme.fg("mdLink", `#${pullRequest.number}`), pullRequest.url);
53
- const sessionSuffix = sessionName ? theme.fg("dim", ` • ${sessionName}`) : "";
54
- const full = theme.fg("dim", pwd) + badge + sessionSuffix;
82
+ const full = theme.fg("dim", pwd) + badge;
55
83
  if (visibleWidth(full) <= width)
56
84
  pathRow = full;
57
85
  else if (visibleWidth(badge) <= width) {
@@ -111,6 +139,11 @@ export class SessionFooter {
111
139
  leftWidth = visibleWidth(left);
112
140
  }
113
141
  const rightWidth = visibleWidth(right);
142
+ this.#nameBudget = Math.max(0, width - leftWidth - rightWidth - 2 - 3);
143
+ if (this.profile === "a1" && sessionName && this.#nameBudget > 0) {
144
+ left += theme.fg("dim", " • ") + this.#nameFrame(sessionName, this.#nameBudget).text;
145
+ leftWidth = visibleWidth(left);
146
+ }
114
147
  let line;
115
148
  if (this.profile === "a1" && leftWidth === 0) {
116
149
  line = " ".repeat(Math.max(0, width - rightWidth)) + right;
@@ -139,6 +172,10 @@ export class SessionFooter {
139
172
  return lines;
140
173
  }
141
174
  }
175
+ function rgb(ansi) {
176
+ const match = /\u001b\[38;2;(\d+);(\d+);(\d+)m/u.exec(ansi);
177
+ return match ? match.slice(1).map(Number) : null;
178
+ }
142
179
  function sanitize(text) { return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim(); }
143
180
  function formatTokens(count) {
144
181
  if (count < 1000)
@@ -4,14 +4,14 @@
4
4
  * Modifications: Retain pinned shell/public renderers. Own image conversion; replace private APIs with
5
5
  * public/local equivalents. Use bare-A1 secondary-accent read ranges and concise collapse shortcut
6
6
  * hints. Restore the enclosing default-shell background after nested renderer resets. Omit
7
- * non-actionable image fallback rows when the terminal has no image protocol. See
7
+ * non-actionable image fallback rows when images are hidden or the terminal has no image protocol. See
8
8
  * docs/architecture/tool-image-presentation.md.
9
9
  * Deviations: current-tool-image-conversion-ownership, read-range-secondary-accent,
10
10
  * concise-tool-expand-shortcut-hint, default-shell-background-reset-scoping,
11
- * unsupported-image-fallback-omission.
11
+ * unsupported-image-fallback-omission, hidden-image-fallback-omission.
12
12
  */
13
13
  import { stripVTControlCharacters } from "node:util";
14
- import { Box, Container, getImageDimensions, imageFallback, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
14
+ import { Box, Container, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
15
15
  import { createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, keyHint, keyText, } from "../../../startup-public.js";
16
16
  import { renderPiModalShortcutHints } from "../../theme.js";
17
17
  import { isPiAccentProjectionEnabled, piTheme } from "../theme/theme.js";
@@ -392,11 +392,7 @@ export class ToolExecutionComponent extends Container {
392
392
  getTextOutput() {
393
393
  if (!this.result)
394
394
  return "";
395
- const text = this.result.content.filter(part => part.type === "text").map(part => stripVTControlCharacters(part.text ?? "").replace(/[\u0000-\u0008\u000b-\u001f\ufff9-\ufffb]/gu, "")).join("\n");
396
- if (this.showImages)
397
- return text;
398
- const indicators = this.result.content.filter(part => part.type === "image").map(part => imageFallback(part.mimeType ?? "image/unknown", part.data && part.mimeType ? getImageDimensions(part.data, part.mimeType) ?? undefined : undefined)).join("\n");
399
- return text && indicators ? `${text}\n${indicators}` : text || indicators;
395
+ return this.result.content.filter(part => part.type === "text").map(part => stripVTControlCharacters(part.text ?? "").replace(/[\u0000-\u0008\u000b-\u001f\ufff9-\ufffb]/gu, "")).join("\n");
400
396
  }
401
397
  formatToolExecution() {
402
398
  const theme = piTheme();
@@ -1,9 +1,10 @@
1
1
  import { type OwnedUiCommand, type OwnedUiExtensionPort, type OwnedUiPromptSuggestionGeneratorPort, type OwnedUiPromptSuggestionReasoning, type OwnedUiPromptSuggestionRequest, type OwnedUiPromptSuggestionResult, type OwnedUiEvent, type OwnedUiSessionBackend, type OwnedUiSessionCatalogPort, type OwnedUiSessionIdentityPort, type OwnedUiSessionPort, type OwnedUiSessionSettingsPort, type OwnedUiSessionViewModel, type OwnedUiSnapshot, type OwnedUiWorkflowPort } from "../../../contracts/owned-ui/index.js";
2
- import type { PiWorkflowHost, PiWorkflowInteractionHost, PiWorkflowRequest, PiWorkflowResult } from "./workflows.js";
2
+ import type { PiWorkflowHost, PiWorkflowRequest, PiWorkflowResult } from "./workflows.js";
3
3
  import type { PiSettingsBridge } from "./settings-bridge.js";
4
4
  import type { PiProjectTrustPreflightPrompt } from "./project-trust-preflight.js";
5
5
  import type { PiSessionForkPrompt, PiSessionSelection } from "./session-selection.js";
6
6
  import { type AdapterCommandResult } from "./command-dispatch.js";
7
+ import { PiSessionNaming } from "./session-naming.js";
7
8
  import { type PiEnginePackageUpdateProbe, type PiEngineRuntimeFactory, type PiRepositoryContextReader } from "./session-runtime.js";
8
9
  import { type PiEngineHostSessionPorts } from "./host-ports.js";
9
10
  import { type PiEngineAnnouncements, type PiSessionPresentationSource } from "./ports.js";
@@ -60,7 +61,7 @@ export declare class PiEngineAdapter implements OwnedUiSessionBackend, OwnedUiPr
60
61
  get announcements(): PiEngineAnnouncements;
61
62
  /** The Pi objects behind pinned selectors and renderers, for the session presenters owner only. */
62
63
  presentationSource(): PiSessionPresentationSource;
63
- setWorkflowInteractionHost(interaction: PiWorkflowInteractionHost): void;
64
+ get naming(): PiSessionNaming;
64
65
  suggestionReasoningPolicy(): OwnedUiPromptSuggestionReasoning;
65
66
  generate(request: OwnedUiPromptSuggestionRequest): Promise<OwnedUiPromptSuggestionResult>;
66
67
  start(): Promise<OwnedUiSessionViewModel>;